diff --git a/packages/llm/src/provider.ts b/packages/llm/src/provider.ts index 7f69583418b..c0406f3da02 100644 --- a/packages/llm/src/provider.ts +++ b/packages/llm/src/provider.ts @@ -1,7 +1,6 @@ -import type { RouteDefaultsInput } from "./route/client" import type { Model, ModelID, ProviderID } from "./schema" -export type ModelOptions = RouteDefaultsInput +export type ModelOptions = Pick /** * Advanced structural provider definition helper. Built-in providers should diff --git a/packages/llm/src/schema/options.ts b/packages/llm/src/schema/options.ts index c02af6d1ed4..9a10304cbde 100644 --- a/packages/llm/src/schema/options.ts +++ b/packages/llm/src/schema/options.ts @@ -134,15 +134,60 @@ export namespace ModelLimits { input instanceof ModelLimits ? input : new ModelLimits(input ?? {}) } +export class ModelDefaults extends Schema.Class("LLM.ModelDefaults")({ + limits: Schema.optional(ModelLimits), + generation: Schema.optional(GenerationOptions), + providerOptions: Schema.optional(ProviderOptions), + http: Schema.optional(HttpOptions), +}) {} + +export namespace ModelDefaults { + export type Input = ModelDefaults | { + readonly limits?: ModelLimits.Input + readonly generation?: GenerationOptions.Input + readonly providerOptions?: ProviderOptions + readonly http?: HttpOptions.Input + } + + /** Normalize selected-model request defaults without applying precedence. */ + export const make = (input: Input) => { + if (input instanceof ModelDefaults) return input + return new ModelDefaults({ + limits: input.limits === undefined ? undefined : ModelLimits.make(input.limits), + generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation), + providerOptions: input.providerOptions, + http: input.http === undefined ? undefined : HttpOptions.make(input.http), + }) + } +} + +export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"]) +export type ModelToolSchemaCompatibility = Schema.Schema.Type + +export class ModelCompatibility extends Schema.Class("LLM.ModelCompatibility")({ + toolSchema: Schema.optional(ModelToolSchemaCompatibility), +}) {} + +export namespace ModelCompatibility { + export type Input = ModelCompatibility | ConstructorParameters[0] + + /** Normalize model/upstream compatibility metadata without projecting requests. */ + export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input)) +} + export class Model { readonly id: ModelID readonly provider: ProviderID readonly route: AnyRoute + readonly defaults?: ModelDefaults + readonly compatibility?: ModelCompatibility constructor(input: Model.ConstructorInput) { this.id = input.id this.provider = input.provider this.route = input.route + this.defaults = input.defaults + this.compatibility = input.compatibility } static make(input: Model.Input) { @@ -150,6 +195,8 @@ export class Model { id: ModelID.make(input.id), provider: ProviderID.make(input.provider), route: input.route, + defaults: input.defaults === undefined ? undefined : ModelDefaults.make(input.defaults), + compatibility: input.compatibility === undefined ? undefined : ModelCompatibility.make(input.compatibility), }) } @@ -158,6 +205,8 @@ export class Model { id: model.id, provider: model.provider, route: model.route, + defaults: model.defaults, + compatibility: model.compatibility, } } @@ -175,11 +224,15 @@ export namespace Model { readonly id: ModelID readonly provider: ProviderID readonly route: AnyRoute + readonly defaults?: ModelDefaults + readonly compatibility?: ModelCompatibility } - export type Input = Omit & { + export type Input = Omit & { readonly id: string | ModelID readonly provider: string | ProviderID + readonly defaults?: ModelDefaults.Input + readonly compatibility?: ModelCompatibility.Input } } diff --git a/packages/llm/test/llm.test.ts b/packages/llm/test/llm.test.ts index 633a4662da1..64346a8bb03 100644 --- a/packages/llm/test/llm.test.ts +++ b/packages/llm/test/llm.test.ts @@ -90,15 +90,47 @@ describe("llm constructors", () => { provider: "fake", route: chatRoute, }) - const updated = Model.update(base, { route: responsesRoute }) + const updated = Model.update(base, { + route: responsesRoute, + defaults: { generation: { maxTokens: 20 } }, + compatibility: { toolSchema: "gemini" }, + }) + const updatedInput = Model.input(updated) expect(updated).toBeInstanceOf(Model) expect(String(updated.id)).toBe("fake-model") expect(updated.route).toBe(responsesRoute) - expect(String(Model.input(updated).provider)).toBe("fake") + expect(updated.defaults?.generation).toEqual({ maxTokens: 20 }) + expect(updated.compatibility).toEqual({ toolSchema: "gemini" }) + expect(updatedInput.defaults).toBe(updated.defaults) + expect(updatedInput.compatibility).toBe(updated.compatibility) + expect(String(updatedInput.provider)).toBe("fake") expect(Model.update(updated, {})).toBe(updated) }) + test("carries model defaults and compatibility through route model selection", () => { + const model = chatRoute.model({ + id: "kimi-k2", + defaults: { + limits: { context: 128_000, output: 8_192 }, + generation: { maxTokens: 1_024, stop: ["END"] }, + providerOptions: { openai: { parallelToolCalls: false } }, + http: { body: { extra_body: true } }, + }, + compatibility: { toolSchema: "moonshot" }, + }) + const request = LLM.request({ model, prompt: "Say hello." }) + + expect(request.model.defaults?.limits).toEqual({ context: 128_000, output: 8_192 }) + expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] }) + expect(request.model.defaults?.providerOptions).toEqual({ openai: { parallelToolCalls: false } }) + expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } }) + expect(request.model.compatibility).toEqual({ toolSchema: "moonshot" }) + expect(request.generation).toBeUndefined() + expect(request.providerOptions).toBeUndefined() + expect(request.http).toBeUndefined() + }) + test("builds tool choices from names and tools", () => { const tool = ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })