Browse Source

fix(provider): enable thinking for google-vertex-anthropic models

Fixes reasoning/thinking not working for Claude models on GCP Vertex AI by correcting the npm package identifier and provider options key mapping.

The issue had two root causes:
1. models.dev API returns npm: '@ai-sdk/google-vertex' for google-vertex-anthropic provider, but variant generation expects '@ai-sdk/google-vertex/anthropic' (subpath import)
2. sdkKey() function didn't map '@ai-sdk/google-vertex/anthropic' to 'anthropic' key, causing thinking options to be wrapped with wrong provider key

Changes:
- Transform npm package to '@ai-sdk/google-vertex/anthropic' for google-vertex-anthropic provider in fromModelsDevModel()
- Add '@ai-sdk/google-vertex/anthropic' case to sdkKey() to return 'anthropic' key
- Add comprehensive tests for npm transformation, variant generation, and providerOptions key mapping
Michael Yochpaz 2 months ago
parent
commit
b110291d38

+ 2 - 1
packages/opencode/src/provider/provider.ts

@@ -592,7 +592,7 @@ export namespace Provider {
     })
   export type Info = z.infer<typeof Info>
 
-  function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
+  export function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
     const m: Model = {
       id: model.id,
       providerID: provider.id,
@@ -603,6 +603,7 @@ export namespace Provider {
         url: provider.api!,
         npm: iife(() => {
           if (provider.id.startsWith("github-copilot")) return "@ai-sdk/github-copilot"
+          if (provider.id === "google-vertex-anthropic") return "@ai-sdk/google-vertex/anthropic"
           return model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible"
         }),
       },

+ 1 - 0
packages/opencode/src/provider/transform.ts

@@ -26,6 +26,7 @@ export namespace ProviderTransform {
       case "@ai-sdk/amazon-bedrock":
         return "bedrock"
       case "@ai-sdk/anthropic":
+      case "@ai-sdk/google-vertex/anthropic":
         return "anthropic"
       case "@ai-sdk/google-vertex":
       case "@ai-sdk/google":

+ 63 - 0
packages/opencode/test/provider/provider.test.ts

@@ -2013,6 +2013,69 @@ test("all variants can be disabled via config", async () => {
   })
 })
 
+test("google-vertex-anthropic transforms npm package to subpath import", () => {
+  // This test verifies that even though models.dev returns "@ai-sdk/google-vertex" as the npm package,
+  // we correctly transform it to "@ai-sdk/google-vertex/anthropic" for proper variant generation
+  const provider = {
+    id: "google-vertex-anthropic",
+    npm: "@ai-sdk/google-vertex",
+    api: "https://vertexai.googleapis.com",
+  }
+  const modelData = {
+    id: "claude-opus-4-5@20251101",
+    name: "Claude Opus 4.5",
+    family: "claude-opus",
+    reasoning: true,
+    attachment: true,
+    tool_call: true,
+    temperature: true,
+    limit: { context: 200000, output: 64000 },
+  }
+
+  const model = Provider.fromModelsDevModel(provider as any, modelData as any)
+
+  expect(model.api.npm).toBe("@ai-sdk/google-vertex/anthropic")
+  expect(model.providerID).toBe("google-vertex-anthropic")
+})
+
+test("google-vertex-anthropic generates thinking variants from transformed npm package", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+        }),
+      )
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    init: async () => {
+      Env.set("GOOGLE_CLOUD_PROJECT", "test-project")
+    },
+    fn: async () => {
+      const providers = await Provider.list()
+      expect(providers["google-vertex-anthropic"]).toBeDefined()
+      const model = providers["google-vertex-anthropic"].models["claude-opus-4-5@20251101"]
+      expect(model).toBeDefined()
+      expect(model.api.npm).toBe("@ai-sdk/google-vertex/anthropic")
+      expect(model.capabilities.reasoning).toBe(true)
+      expect(model.variants).toBeDefined()
+      expect(model.variants!["high"]).toBeDefined()
+      expect(model.variants!["max"]).toBeDefined()
+      expect(model.variants!["high"].thinking).toEqual({
+        type: "enabled",
+        budgetTokens: 16000,
+      })
+      expect(model.variants!["max"].thinking).toEqual({
+        type: "enabled",
+        budgetTokens: 31999,
+      })
+    },
+  })
+})
+
 test("variant config merges with generated variants", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {

+ 193 - 0
packages/opencode/test/provider/transform.test.ts

@@ -195,6 +195,42 @@ describe("ProviderTransform.maxOutputTokens", () => {
       expect(result).toBe(OUTPUT_TOKEN_MAX)
     })
   })
+
+  describe("anthropic with thinking options - google-vertex/anthropic", () => {
+    test("returns 32k when budgetTokens + 32k <= modelLimit", () => {
+      const modelLimit = 100000
+      const options = {
+        thinking: {
+          type: "enabled",
+          budgetTokens: 10000,
+        },
+      }
+      const result = ProviderTransform.maxOutputTokens(
+        "@ai-sdk/google-vertex/anthropic",
+        options,
+        modelLimit,
+        OUTPUT_TOKEN_MAX,
+      )
+      expect(result).toBe(OUTPUT_TOKEN_MAX)
+    })
+
+    test("returns modelLimit - budgetTokens when budgetTokens + 32k > modelLimit", () => {
+      const modelLimit = 50000
+      const options = {
+        thinking: {
+          type: "enabled",
+          budgetTokens: 30000,
+        },
+      }
+      const result = ProviderTransform.maxOutputTokens(
+        "@ai-sdk/google-vertex/anthropic",
+        options,
+        modelLimit,
+        OUTPUT_TOKEN_MAX,
+      )
+      expect(result).toBe(20000)
+    })
+  })
 })
 
 describe("ProviderTransform.schema - gemini array items", () => {
@@ -1669,6 +1705,34 @@ describe("ProviderTransform.variants", () => {
     })
   })
 
+  describe("@ai-sdk/google-vertex/anthropic", () => {
+    test("returns high and max with thinking budgetTokens", () => {
+      const model = createMockModel({
+        id: "google-vertex-anthropic/claude-opus-4-5@20251101",
+        providerID: "google-vertex-anthropic",
+        api: {
+          id: "claude-opus-4-5@20251101",
+          url: "https://vertexai.googleapis.com",
+          npm: "@ai-sdk/google-vertex/anthropic",
+        },
+      })
+      const result = ProviderTransform.variants(model)
+      expect(Object.keys(result)).toEqual(["high", "max"])
+      expect(result.high).toEqual({
+        thinking: {
+          type: "enabled",
+          budgetTokens: 16000,
+        },
+      })
+      expect(result.max).toEqual({
+        thinking: {
+          type: "enabled",
+          budgetTokens: 31999,
+        },
+      })
+    })
+  })
+
   describe("@ai-sdk/cohere", () => {
     test("returns empty object", () => {
       const model = createMockModel({
@@ -1725,3 +1789,132 @@ describe("ProviderTransform.variants", () => {
     })
   })
 })
+
+describe("ProviderTransform.providerOptions", () => {
+  const createMockModel = (overrides: Partial<any> = {}): any => ({
+    id: "test/test-model",
+    providerID: "test",
+    api: {
+      id: "test-model",
+      url: "https://api.test.com",
+      npm: "@ai-sdk/openai",
+    },
+    name: "Test Model",
+    capabilities: {
+      temperature: true,
+      reasoning: true,
+      attachment: true,
+      toolcall: true,
+      input: { text: true, audio: false, image: true, video: false, pdf: false },
+      output: { text: true, audio: false, image: false, video: false, pdf: false },
+      interleaved: false,
+    },
+    cost: {
+      input: 0.001,
+      output: 0.002,
+      cache: { read: 0.0001, write: 0.0002 },
+    },
+    limit: {
+      context: 128000,
+      output: 8192,
+    },
+    status: "active",
+    options: {},
+    headers: {},
+    release_date: "2024-01-01",
+    ...overrides,
+  })
+
+  describe("anthropic providers", () => {
+    test("wraps options with 'anthropic' key for @ai-sdk/anthropic", () => {
+      const model = createMockModel({
+        id: "anthropic/claude-3-5-sonnet",
+        providerID: "anthropic",
+        api: {
+          id: "claude-3-5-sonnet-20241022",
+          url: "https://api.anthropic.com",
+          npm: "@ai-sdk/anthropic",
+        },
+      })
+      const options = { thinking: { type: "enabled", budgetTokens: 16000 } }
+      const result = ProviderTransform.providerOptions(model, options)
+      expect(result).toEqual({
+        anthropic: { thinking: { type: "enabled", budgetTokens: 16000 } },
+      })
+    })
+
+    test("wraps options with 'anthropic' key for @ai-sdk/google-vertex/anthropic", () => {
+      const model = createMockModel({
+        id: "google-vertex-anthropic/claude-opus-4-5@20251101",
+        providerID: "google-vertex-anthropic",
+        api: {
+          id: "claude-opus-4-5@20251101",
+          url: "https://vertexai.googleapis.com",
+          npm: "@ai-sdk/google-vertex/anthropic",
+        },
+      })
+      const options = { thinking: { type: "enabled", budgetTokens: 16000 } }
+      const result = ProviderTransform.providerOptions(model, options)
+      expect(result).toEqual({
+        anthropic: { thinking: { type: "enabled", budgetTokens: 16000 } },
+      })
+    })
+  })
+
+  describe("google providers", () => {
+    test("wraps options with 'google' key for @ai-sdk/google-vertex", () => {
+      const model = createMockModel({
+        id: "google-vertex/gemini-2.5-pro",
+        providerID: "google-vertex",
+        api: {
+          id: "gemini-2.5-pro",
+          url: "https://vertexai.googleapis.com",
+          npm: "@ai-sdk/google-vertex",
+        },
+      })
+      const options = { thinkingConfig: { thinkingBudget: 16000 } }
+      const result = ProviderTransform.providerOptions(model, options)
+      expect(result).toEqual({
+        google: { thinkingConfig: { thinkingBudget: 16000 } },
+      })
+    })
+  })
+
+  describe("openai providers", () => {
+    test("wraps options with 'openai' key for @ai-sdk/openai", () => {
+      const model = createMockModel({
+        id: "openai/gpt-5",
+        providerID: "openai",
+        api: {
+          id: "gpt-5",
+          url: "https://api.openai.com",
+          npm: "@ai-sdk/openai",
+        },
+      })
+      const options = { reasoningEffort: "high" }
+      const result = ProviderTransform.providerOptions(model, options)
+      expect(result).toEqual({
+        openai: { reasoningEffort: "high" },
+      })
+    })
+  })
+
+  describe("custom providers", () => {
+    test("wraps options with providerID when npm package has no sdkKey mapping", () => {
+      const model = createMockModel({
+        id: "custom/model",
+        providerID: "custom-provider",
+        api: {
+          id: "model",
+          url: "https://api.custom.com",
+          npm: "@ai-sdk/custom",
+        },
+      })
+      const options = { customOption: true }
+      const result = ProviderTransform.providerOptions(model, options)
+      expect(result).toEqual({
+        "custom-provider": { customOption: true },
+      })
+    })
+  })
+})