Просмотр исходного кода

feat(ai): expose model compatibility options (#40942)

Aiden Cline 1 неделя назад
Родитель
Сommit
dcae95e2bb

+ 1 - 0
packages/ai/src/schema/options.ts

@@ -178,6 +178,7 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
   toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
   reasoningField: Schema.optional(Schema.String),
   maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
+  requireFinishReason: Schema.optional(Schema.Boolean),
 }) {}
 
 export namespace LanguageModelCompatibility {

+ 2 - 2
packages/ai/test/llm.test.ts

@@ -102,7 +102,7 @@ describe("llm constructors", () => {
     const updated = LanguageModel.update(base, {
       route: responsesRoute,
       defaults: { generation: { maxTokens: 20 } },
-      compatibility: { toolSchema: "gemini" },
+      compatibility: { toolSchema: "gemini", requireFinishReason: false },
     })
     const updatedInput = LanguageModel.input(updated)
 
@@ -110,7 +110,7 @@ describe("llm constructors", () => {
     expect(String(updated.id)).toBe("fake-model")
     expect(updated.route).toBe(responsesRoute)
     expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
-    expect(updated.compatibility).toEqual({ toolSchema: "gemini" })
+    expect(updated.compatibility).toEqual({ toolSchema: "gemini", requireFinishReason: false })
     expect(updatedInput.defaults).toBe(updated.defaults)
     expect(updatedInput.compatibility).toBe(updated.compatibility)
     expect(String(updatedInput.provider)).toBe("fake")

+ 7 - 1
packages/client/src/promise/generated/types.ts

@@ -169,6 +169,8 @@ export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: nu
 
 export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
 
+export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
+
 export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
 
 export type ModelVariant = {
@@ -1230,7 +1232,11 @@ export type SessionToolCalled = {
 
 export type ToolContent1 = ToolTextContent | ToolFileContent1
 
-export type ModelCompatibility = { reasoningField?: ModelReasoningField }
+export type ModelCompatibility = {
+  reasoningField?: ModelReasoningField
+  maxTokensField?: ModelMaxTokensField
+  requireFinishReason?: boolean
+}
 
 export type ModelCost = {
   tier?: { type: "context"; size: number }

+ 10 - 2
packages/core/test/config/provider.test.ts

@@ -237,7 +237,11 @@ describe("ConfigProviderPlugin.Plugin", () => {
                   models: {
                     chat: {
                       name: "First",
-                      compatibility: { reasoningField: "vendor_reasoning" },
+                      compatibility: {
+                        reasoningField: "vendor_reasoning",
+                        maxTokensField: "max_completion_tokens",
+                        requireFinishReason: false,
+                      },
                       capabilities: { tools: true, input: ["text"], output: ["text"] },
                       disabled: true,
                       limit: { context: 100, output: 50 },
@@ -318,7 +322,11 @@ describe("ConfigProviderPlugin.Plugin", () => {
         expect(model.id).toBe(modelID)
         expect(model.modelID).toBe(Model.ID.make("api-chat"))
         expect(model.name).toBe("Last")
-        expect(model.compatibility).toEqual({ reasoningField: "vendor_reasoning" })
+        expect(model.compatibility).toEqual({
+          reasoningField: "vendor_reasoning",
+          maxTokensField: "max_completion_tokens",
+          requireFinishReason: false,
+        })
         expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
         expect(model.enabled).toBe(false)
         expect(model.limit).toEqual({ context: 100, output: 75 })

+ 11 - 2
packages/core/test/model-resolver.test.ts

@@ -194,7 +194,11 @@ describe("ModelResolver", () => {
     Effect.gen(function* () {
       const resolved = yield* ModelResolver.fromCatalogModel(
         model(Provider.aisdk("@ai-sdk/openai-compatible"), {
-          compatibility: { reasoningField: "vendor_reasoning" },
+          compatibility: {
+            reasoningField: "vendor_reasoning",
+            maxTokensField: "max_completion_tokens",
+            requireFinishReason: false,
+          },
           settings: {
             apiKey: "settings-secret",
             baseURL: "https://compatible.example/v1",
@@ -204,7 +208,8 @@ describe("ModelResolver", () => {
           body: {},
         }),
       )
-      const request = LLM.request({ model: resolved, prompt: "Hello" })
+      const request = LLM.request({ model: resolved, prompt: "Hello", generation: { maxTokens: 10 } })
+      const prepared = yield* compileRequest(request)
       const headers = yield* resolved.route.auth.apply({
         request,
         method: "POST",
@@ -216,6 +221,10 @@ describe("ModelResolver", () => {
       expect(headers.authorization).toBe("Bearer settings-secret")
       expect(resolved.route.id).toBe("openai-compatible-chat")
       expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning")
+      expect(resolved.compatibility?.maxTokensField).toBe("max_completion_tokens")
+      expect(resolved.compatibility?.requireFinishReason).toBe(false)
+      expect(prepared.body).toMatchObject({ max_completion_tokens: 10 })
+      expect(prepared.body).not.toHaveProperty("max_tokens")
       expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
       expect(resolved.route.defaults.http?.body).toEqual({})
     }),

+ 7 - 0
packages/schema/src/model.ts

@@ -47,9 +47,16 @@ export const ReasoningField: Schema.Codec<ReasoningField> = Schema.Union([
   Schema.String,
 ]).annotate({ identifier: "Model.ReasoningField" })
 
+export const MaxTokensField = Schema.Literals(["max_completion_tokens", "max_tokens"]).annotate({
+  identifier: "Model.MaxTokensField",
+})
+export type MaxTokensField = typeof MaxTokensField.Type
+
 export interface Compatibility extends Schema.Schema.Type<typeof Compatibility> {}
 export const Compatibility = Schema.Struct({
   reasoningField: ReasoningField.pipe(optional),
+  maxTokensField: MaxTokensField.pipe(optional),
+  requireFinishReason: Schema.Boolean.pipe(optional),
 }).annotate({ identifier: "Model.Compatibility" })
 
 export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {}

+ 19 - 0
packages/schema/test/model.test.ts

@@ -30,3 +30,22 @@ describe("Model.ReasoningField", () => {
       expect(decode(field)).toBe(field)
   })
 })
+
+describe("Model.Compatibility", () => {
+  test("decodes model compatibility overrides", () => {
+    const decode = Schema.decodeUnknownSync(Model.Compatibility)
+
+    expect(decode({})).toEqual({})
+    expect(
+      decode({
+        reasoningField: "vendor_reasoning",
+        maxTokensField: "max_completion_tokens",
+        requireFinishReason: false,
+      }),
+    ).toEqual({
+      reasoningField: "vendor_reasoning",
+      maxTokensField: "max_completion_tokens",
+      requireFinishReason: false,
+    })
+  })
+})