From e00ca76549410c5209a72b6d9754e67818426cbc Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Mon, 12 Jan 2026 22:30:49 +1100 Subject: [PATCH 1/3] feat(opencode): Add Databricks provider support Add Databricks Foundation Model APIs as a new provider supporting OpenAI-compatible endpoints via /serving-endpoints. Authentication methods (in priority order): - Personal Access Token (DATABRICKS_TOKEN or stored auth) - OAuth M2M (DATABRICKS_CLIENT_ID + DATABRICKS_CLIENT_SECRET) - Azure AD Service Principal (ARM_CLIENT_ID/SECRET/TENANT_ID) Includes default model definitions for Claude, Llama, GPT-5, and Gemini models available through Databricks pay-per-token endpoints. Closes #7983 --- packages/opencode/src/cli/cmd/auth.ts | 13 + packages/opencode/src/provider/provider.ts | 276 ++++++++++++ packages/opencode/test/preload.ts | 7 + .../opencode/test/provider/databricks.test.ts | 405 ++++++++++++++++++ 4 files changed, 701 insertions(+) create mode 100644 packages/opencode/test/provider/databricks.test.ts diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 3dd7bcc35dd..1a3409995dc 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -276,6 +276,7 @@ export const AuthLoginCommand = cmd({ google: 4, openrouter: 5, vercel: 6, + databricks: 7, } let provider = await prompts.autocomplete({ message: "Select provider", @@ -344,6 +345,18 @@ export const AuthLoginCommand = cmd({ ) } + if (provider === "databricks") { + prompts.log.info( + "Databricks Foundation Model APIs authentication:\n" + + " Set DATABRICKS_HOST to your workspace URL (e.g., https://dbc-xxx.cloud.databricks.com)\n\n" + + "Authentication options (in priority order):\n" + + " 1. PAT: Enter your Personal Access Token below, or set DATABRICKS_TOKEN\n" + + " Create at: Workspace > Settings > Developer > Access tokens\n" + + " 2. OAuth M2M (Azure): Set DATABRICKS_CLIENT_ID + DATABRICKS_CLIENT_SECRET\n" + + " 3. Azure AD: Set ARM_CLIENT_ID + ARM_CLIENT_SECRET + ARM_TENANT_ID", + ) + } + if (provider === "opencode") { prompts.log.info("Create an api key at https://opencode.ai/auth") } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 9b01eae9e9b..3d40839370f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -438,6 +438,269 @@ export namespace Provider { }, } }, + databricks: async (input) => { + const config = await Config.get() + const providerConfig = config.provider?.["databricks"] + const auth = await Auth.get("databricks") + + // Host resolution: 1) config file, 2) env var + const configHost = providerConfig?.options?.baseURL ?? providerConfig?.options?.host + const envHost = Env.get("DATABRICKS_HOST") + const host = configHost ?? envHost + + if (!host) return { autoload: false } + + // Authentication precedence: + // 1. PAT token (DATABRICKS_TOKEN or stored auth) + // 2. OAuth M2M (DATABRICKS_CLIENT_ID + DATABRICKS_CLIENT_SECRET) for Azure + // 3. Azure AD Service Principal (azure_client_id + azure_client_secret + azure_tenant_id) + const token = Env.get("DATABRICKS_TOKEN") ?? (auth?.type === "api" ? auth.key : undefined) + + // OAuth M2M credentials for Azure Databricks + const clientId = + Env.get("DATABRICKS_CLIENT_ID") ?? + providerConfig?.options?.clientId ?? + (auth?.type === "oauth" ? (auth as any).clientId : undefined) + const clientSecret = + Env.get("DATABRICKS_CLIENT_SECRET") ?? + providerConfig?.options?.clientSecret ?? + (auth?.type === "oauth" ? (auth as any).clientSecret : undefined) + + // Azure AD Service Principal credentials + const azureClientId = Env.get("ARM_CLIENT_ID") ?? providerConfig?.options?.azureClientId + const azureClientSecret = Env.get("ARM_CLIENT_SECRET") ?? providerConfig?.options?.azureClientSecret + const azureTenantId = Env.get("ARM_TENANT_ID") ?? providerConfig?.options?.azureTenantId + + // Determine which auth method to use + const hasOAuthM2M = clientId && clientSecret + const hasAzureAD = azureClientId && azureClientSecret && azureTenantId + const hasPAT = Boolean(token) + + if (!hasPAT && !hasOAuthM2M && !hasAzureAD) return { autoload: false } + + // Databricks Foundation Model APIs use OpenAI-compatible endpoints + // The base URL format is: https:///serving-endpoints + // If baseURL is already a full path (includes /serving-endpoints), use it as-is + const baseURL = host.includes("/serving-endpoints") + ? host.replace(/\/$/, "") + : host.replace(/\/$/, "") + "/serving-endpoints" + + // For OAuth M2M, we need to fetch an access token + let accessToken: string | undefined = token + if (!accessToken && hasOAuthM2M) { + // Fetch OAuth token from Databricks OIDC endpoint + const tokenEndpoint = `${host.replace(/\/$/, "")}/oidc/v1/token` + try { + const response = await fetch(tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`, + }, + body: "grant_type=client_credentials&scope=all-apis", + }) + if (response.ok) { + const data = (await response.json()) as { access_token: string } + accessToken = data.access_token + } + } catch (e) { + log.error("Failed to fetch Databricks OAuth token", { error: e }) + } + } + + // For Azure AD Service Principal, we need to fetch an Azure AD token first + if (!accessToken && hasAzureAD) { + try { + // Get Azure AD token for Databricks resource + const aadTokenEndpoint = `https://login.microsoftonline.com/${azureTenantId}/oauth2/v2.0/token` + const response = await fetch(aadTokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "client_credentials", + client_id: azureClientId, + client_secret: azureClientSecret, + scope: "2ff814a6-3304-4ab8-85cb-cd0e6f879c1d/.default", // Azure Databricks resource ID + }).toString(), + }) + if (response.ok) { + const data = (await response.json()) as { access_token: string } + accessToken = data.access_token + } + } catch (e) { + log.error("Failed to fetch Azure AD token for Databricks", { error: e }) + } + } + + if (!accessToken) return { autoload: false } + + // Define default Databricks Foundation Model API endpoints + // These are the pay-per-token endpoints available in most workspaces + // Users can override or add more models in their opencode.json config + const defaultModels: Record = { + "databricks-claude-sonnet-4": { + id: "databricks-claude-sonnet-4", + name: "Claude Sonnet 4 (Databricks)", + family: "claude-sonnet", + attachment: true, + reasoning: false, + tool_call: true, + temperature: true, + release_date: "2025-05-22", + modalities: { input: ["text", "image"], output: ["text"] }, + cost: { input: 3, output: 15, cache_read: 0.3 }, + limit: { context: 200000, output: 64000 }, + options: {}, + }, + "databricks-claude-sonnet-4-5": { + id: "databricks-claude-sonnet-4-5", + name: "Claude Sonnet 4.5 (Databricks)", + family: "claude-sonnet", + attachment: true, + reasoning: true, + tool_call: true, + temperature: true, + release_date: "2025-10-22", + modalities: { input: ["text", "image"], output: ["text"] }, + cost: { input: 3, output: 15, cache_read: 0.3 }, + limit: { context: 200000, output: 64000 }, + options: {}, + }, + "databricks-claude-haiku-4-5": { + id: "databricks-claude-haiku-4-5", + name: "Claude Haiku 4.5 (Databricks)", + family: "claude-haiku", + attachment: true, + reasoning: false, + tool_call: true, + temperature: true, + release_date: "2025-10-22", + modalities: { input: ["text", "image"], output: ["text"] }, + cost: { input: 0.8, output: 4, cache_read: 0.08 }, + limit: { context: 200000, output: 8192 }, + options: {}, + }, + "databricks-claude-opus-4-5": { + id: "databricks-claude-opus-4-5", + name: "Claude Opus 4.5 (Databricks)", + family: "claude-opus", + attachment: true, + reasoning: true, + tool_call: true, + temperature: true, + release_date: "2025-10-22", + modalities: { input: ["text", "image"], output: ["text"] }, + cost: { input: 15, output: 75, cache_read: 1.5 }, + limit: { context: 200000, output: 32000 }, + options: {}, + }, + "databricks-meta-llama-3-3-70b-instruct": { + id: "databricks-meta-llama-3-3-70b-instruct", + name: "Meta Llama 3.3 70B Instruct (Databricks)", + family: "llama-3.3", + attachment: false, + reasoning: false, + tool_call: true, + temperature: true, + release_date: "2024-12-06", + modalities: { input: ["text"], output: ["text"] }, + cost: { input: 0.65, output: 2.56 }, + limit: { context: 128000, output: 4096 }, + options: {}, + }, + "databricks-gpt-5-mini": { + id: "databricks-gpt-5-mini", + name: "GPT-5 Mini (Databricks)", + family: "gpt-5", + attachment: true, + reasoning: false, + tool_call: true, + temperature: true, + release_date: "2025-07-18", + modalities: { input: ["text", "image"], output: ["text"] }, + cost: { input: 0.15, output: 0.6, cache_read: 0.075 }, + limit: { context: 128000, output: 16384 }, + options: {}, + }, + "databricks-gpt-5-nano": { + id: "databricks-gpt-5-nano", + name: "GPT-5 Nano (Databricks)", + family: "gpt-5", + attachment: true, + reasoning: false, + tool_call: true, + temperature: true, + release_date: "2025-07-18", + modalities: { input: ["text", "image"], output: ["text"] }, + cost: { input: 0.075, output: 0.3, cache_read: 0.0375 }, + limit: { context: 128000, output: 16384 }, + options: {}, + }, + "databricks-gemini-2-5-flash": { + id: "databricks-gemini-2-5-flash", + name: "Gemini 2.5 Flash (Databricks)", + family: "gemini-2.5", + attachment: true, + reasoning: true, + tool_call: true, + temperature: true, + release_date: "2025-04-17", + modalities: { input: ["text", "image", "audio", "video"], output: ["text"] }, + cost: { input: 0.15, output: 0.6, cache_read: 0.0375 }, + limit: { context: 1048576, output: 65536 }, + options: {}, + }, + "databricks-gemini-3-flash": { + id: "databricks-gemini-3-flash", + name: "Gemini 3 Flash (Databricks)", + family: "gemini-3", + attachment: true, + reasoning: true, + tool_call: true, + temperature: true, + release_date: "2025-12-11", + modalities: { input: ["text", "image", "audio", "video"], output: ["text"] }, + cost: { input: 0.1, output: 0.4, cache_read: 0.025 }, + limit: { context: 1048576, output: 65536 }, + options: {}, + }, + "databricks-gemini-3-pro": { + id: "databricks-gemini-3-pro", + name: "Gemini 3 Pro (Databricks)", + family: "gemini-3", + attachment: true, + reasoning: true, + tool_call: true, + temperature: true, + release_date: "2025-12-11", + modalities: { input: ["text", "image", "audio", "video"], output: ["text"] }, + cost: { input: 1.25, output: 10, cache_read: 0.3125 }, + limit: { context: 2097152, output: 65536 }, + options: {}, + }, + } + + // Add default models to the input provider if not already defined + for (const [modelID, model] of Object.entries(defaultModels)) { + if (!input.models[modelID]) { + input.models[modelID] = model + } + } + + return { + autoload: true, + async getModel(sdk: any, modelID: string, _options?: Record) { + return sdk.chat(modelID) + }, + options: { + baseURL, + apiKey: accessToken, + headers: { + "User-Agent": "opencode", + }, + }, + } + }, } export const Model = z @@ -642,6 +905,19 @@ export namespace Provider { } } + // Add Databricks provider for Foundation Model APIs + // This provider is not in models.dev so we create it programmatically + if (!database["databricks"]) { + database["databricks"] = { + id: "databricks", + name: "Databricks", + source: "custom", + env: ["DATABRICKS_TOKEN"], + options: {}, + models: {}, + } + } + function mergeProvider(providerID: string, provider: Partial) { const existing = providers[providerID] if (existing) { diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 35b0b6c7642..abb393b7753 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -54,6 +54,13 @@ delete process.env["DEEPSEEK_API_KEY"] delete process.env["FIREWORKS_API_KEY"] delete process.env["CEREBRAS_API_KEY"] delete process.env["SAMBANOVA_API_KEY"] +delete process.env["DATABRICKS_HOST"] +delete process.env["DATABRICKS_TOKEN"] +delete process.env["DATABRICKS_CLIENT_ID"] +delete process.env["DATABRICKS_CLIENT_SECRET"] +delete process.env["ARM_CLIENT_ID"] +delete process.env["ARM_CLIENT_SECRET"] +delete process.env["ARM_TENANT_ID"] // Now safe to import from src/ const { Log } = await import("../src/util/log") diff --git a/packages/opencode/test/provider/databricks.test.ts b/packages/opencode/test/provider/databricks.test.ts new file mode 100644 index 00000000000..7b935fd35b8 --- /dev/null +++ b/packages/opencode/test/provider/databricks.test.ts @@ -0,0 +1,405 @@ +import { test, expect, mock } from "bun:test" +import path from "path" + +// === Mocks === +// These mocks are required because Provider.list() triggers: +// 1. BunProc.install() for various packages +// 2. Plugin.list() which calls BunProc.install() for default plugins +// Without mocks, these would attempt real package installations that timeout in tests. + +mock.module("../../src/bun/index", () => ({ + BunProc: { + install: async (pkg: string) => pkg, + run: async () => { + throw new Error("BunProc.run should not be called in tests") + }, + which: () => process.execPath, + InstallFailedError: class extends Error {}, + }, +})) + +mock.module("@aws-sdk/credential-providers", () => ({ + fromNodeProviderChain: () => async () => ({ + accessKeyId: "mock-access-key-id", + secretAccessKey: "mock-secret-access-key", + }), +})) + +const mockPlugin = () => ({}) +mock.module("opencode-copilot-auth", () => ({ default: mockPlugin })) +mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin })) + +// Import after mocks are set up +const { tmpdir } = await import("../fixture/fixture") +const { Instance } = await import("../../src/project/instance") +const { Provider } = await import("../../src/provider/provider") +const { Env } = await import("../../src/env") +const { Global } = await import("../../src/global") + +test("Databricks: loads when DATABRICKS_HOST and DATABRICKS_TOKEN are set", 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("DATABRICKS_HOST", "https://my-workspace.cloud.databricks.com") + Env.set("DATABRICKS_TOKEN", "test-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["databricks"]).toBeDefined() + expect(providers["databricks"].name).toBe("Databricks") + }, + }) +}) + +test("Databricks: does not load when only DATABRICKS_HOST is set (no auth)", 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("DATABRICKS_HOST", "https://my-workspace.cloud.databricks.com") + // No token set + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["databricks"]).toBeUndefined() + }, + }) +}) + +test("Databricks: config host takes precedence over DATABRICKS_HOST env var", 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", + provider: { + databricks: { + options: { + host: "https://config-workspace.cloud.databricks.com", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("DATABRICKS_HOST", "https://env-workspace.cloud.databricks.com") + Env.set("DATABRICKS_TOKEN", "test-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["databricks"]).toBeDefined() + // baseURL should use config host + expect(providers["databricks"].options.baseURL).toContain("config-workspace") + }, + }) +}) + +test("Databricks: baseURL option takes precedence", 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", + provider: { + databricks: { + options: { + baseURL: "https://custom-url.cloud.databricks.com/serving-endpoints", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("DATABRICKS_HOST", "https://env-workspace.cloud.databricks.com") + Env.set("DATABRICKS_TOKEN", "test-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["databricks"]).toBeDefined() + expect(providers["databricks"].options.baseURL).toBe("https://custom-url.cloud.databricks.com/serving-endpoints") + }, + }) +}) + +test("Databricks: includes default models", 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("DATABRICKS_HOST", "https://my-workspace.cloud.databricks.com") + Env.set("DATABRICKS_TOKEN", "test-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["databricks"]).toBeDefined() + const models = Object.keys(providers["databricks"].models) + // Should include Claude models + expect(models.some((m) => m.includes("claude"))).toBe(true) + // Should include Llama models + expect(models.some((m) => m.includes("llama"))).toBe(true) + }, + }) +}) + +test("Databricks: custom models via config", 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", + provider: { + databricks: { + models: { + "custom-endpoint": { + name: "Custom Endpoint", + tool_call: true, + limit: { context: 100000, output: 10000 }, + }, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("DATABRICKS_HOST", "https://my-workspace.cloud.databricks.com") + Env.set("DATABRICKS_TOKEN", "test-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["databricks"]).toBeDefined() + expect(providers["databricks"].models["custom-endpoint"]).toBeDefined() + expect(providers["databricks"].models["custom-endpoint"].name).toBe("Custom Endpoint") + }, + }) +}) + +test("Databricks: loads when bearer token from auth.json is present", 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", + }), + ) + }, + }) + + const authPath = path.join(Global.Path.data, "auth.json") + await Bun.write( + authPath, + JSON.stringify({ + databricks: { + type: "api", + key: "test-bearer-token", + }, + }), + ) + + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("DATABRICKS_HOST", "https://my-workspace.cloud.databricks.com") + // No DATABRICKS_TOKEN env var - using auth.json instead + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["databricks"]).toBeDefined() + }, + }) +}) + +test("Databricks: appends /serving-endpoints to host URL", 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("DATABRICKS_HOST", "https://my-workspace.cloud.databricks.com") + Env.set("DATABRICKS_TOKEN", "test-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["databricks"]).toBeDefined() + expect(providers["databricks"].options.baseURL).toBe( + "https://my-workspace.cloud.databricks.com/serving-endpoints", + ) + }, + }) +}) + +test("Databricks: does not duplicate /serving-endpoints if already present", 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", + provider: { + databricks: { + options: { + host: "https://my-workspace.cloud.databricks.com/serving-endpoints", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("DATABRICKS_TOKEN", "test-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["databricks"]).toBeDefined() + // Should not duplicate /serving-endpoints + expect(providers["databricks"].options.baseURL).toBe( + "https://my-workspace.cloud.databricks.com/serving-endpoints", + ) + }, + }) +}) + +test("Databricks: sets User-Agent header", 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("DATABRICKS_HOST", "https://my-workspace.cloud.databricks.com") + Env.set("DATABRICKS_TOKEN", "test-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["databricks"]).toBeDefined() + expect(providers["databricks"].options.headers["User-Agent"]).toBe("opencode") + }, + }) +}) + +// OAuth M2M tests - note: these test the config parsing, not actual token fetching +// since we'd need to mock the OAuth endpoint + +test("Databricks: OAuth M2M credentials via config", 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", + provider: { + databricks: { + options: { + host: "https://my-workspace.cloud.databricks.com", + clientId: "test-client-id", + clientSecret: "test-client-secret", + }, + }, + }, + }), + ) + }, + }) + // This test verifies that the config is parsed correctly + // The actual OAuth flow would require mocking the fetch call + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Without a way to mock the OAuth endpoint, this will return autoload: false + // because the token fetch will fail. We're just verifying config parsing works. + const providers = await Provider.list() + // Provider won't load because OAuth token fetch fails (no mock endpoint) + // This is expected behavior - we'd need to mock fetch for a full test + }, + }) +}) + +test("Databricks: model capabilities are set correctly", 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("DATABRICKS_HOST", "https://my-workspace.cloud.databricks.com") + Env.set("DATABRICKS_TOKEN", "test-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["databricks"]).toBeDefined() + // Check Claude model capabilities + const claudeModel = providers["databricks"].models["databricks-claude-sonnet-4"] + expect(claudeModel).toBeDefined() + expect(claudeModel.capabilities.toolcall).toBe(true) + expect(claudeModel.capabilities.attachment).toBe(true) + }, + }) +}) From bf1de90a562299db0b33fe5ba1f9a7545aad6b8a Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Mon, 12 Jan 2026 22:37:13 +1100 Subject: [PATCH 2/3] feat(opencode): Add Azure CLI auth for Databricks Add Azure CLI as a fallback authentication method for Azure Databricks workspaces (.azuredatabricks.net). When no explicit credentials are provided, attempts to use 'az account get-access-token' if logged in. Also updates default models to current Databricks Foundation Model API offerings (Claude 3.7/4/4.5, Llama 3.1/3.3/4). --- packages/opencode/src/cli/cmd/auth.ts | 5 +- packages/opencode/src/provider/provider.ts | 114 +++++++++++------- .../opencode/test/provider/databricks.test.ts | 6 + 3 files changed, 77 insertions(+), 48 deletions(-) diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 1a3409995dc..85508c24f5a 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -352,8 +352,9 @@ export const AuthLoginCommand = cmd({ "Authentication options (in priority order):\n" + " 1. PAT: Enter your Personal Access Token below, or set DATABRICKS_TOKEN\n" + " Create at: Workspace > Settings > Developer > Access tokens\n" + - " 2. OAuth M2M (Azure): Set DATABRICKS_CLIENT_ID + DATABRICKS_CLIENT_SECRET\n" + - " 3. Azure AD: Set ARM_CLIENT_ID + ARM_CLIENT_SECRET + ARM_TENANT_ID", + " 2. OAuth M2M: Set DATABRICKS_CLIENT_ID + DATABRICKS_CLIENT_SECRET\n" + + " 3. Azure AD Service Principal: Set ARM_CLIENT_ID + ARM_CLIENT_SECRET + ARM_TENANT_ID\n" + + " 4. Azure CLI (auto): For Azure Databricks, will use 'az account get-access-token' if logged in", ) } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 3d40839370f..b6c1c18e3b1 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -475,8 +475,10 @@ export namespace Provider { const hasOAuthM2M = clientId && clientSecret const hasAzureAD = azureClientId && azureClientSecret && azureTenantId const hasPAT = Boolean(token) + // Check if Azure CLI is available for Azure Databricks workspaces + const isAzureDatabricks = host.includes("azuredatabricks.net") - if (!hasPAT && !hasOAuthM2M && !hasAzureAD) return { autoload: false } + if (!hasPAT && !hasOAuthM2M && !hasAzureAD && !isAzureDatabricks) return { autoload: false } // Databricks Foundation Model APIs use OpenAI-compatible endpoints // The base URL format is: https:///serving-endpoints @@ -532,6 +534,26 @@ export namespace Provider { } } + // For Azure Databricks, try Azure CLI as a fallback + if (!accessToken && isAzureDatabricks) { + try { + // Try to get token from Azure CLI + const proc = Bun.spawn( + ["az", "account", "get-access-token", "--resource", "2ff814a6-3304-4ab8-85cb-cd0e6f879c1d", "-o", "json"], + { stdout: "pipe", stderr: "pipe" }, + ) + const output = await new Response(proc.stdout).text() + const exitCode = await proc.exited + if (exitCode === 0) { + const data = JSON.parse(output) as { accessToken: string } + accessToken = data.accessToken + log.info("Using Azure CLI token for Databricks authentication") + } + } catch (e) { + log.debug("Azure CLI not available for Databricks auth", { error: e }) + } + } + if (!accessToken) return { autoload: false } // Define default Databricks Foundation Model API endpoints @@ -608,74 +630,74 @@ export namespace Provider { limit: { context: 128000, output: 4096 }, options: {}, }, - "databricks-gpt-5-mini": { - id: "databricks-gpt-5-mini", - name: "GPT-5 Mini (Databricks)", - family: "gpt-5", + "databricks-claude-3-7-sonnet": { + id: "databricks-claude-3-7-sonnet", + name: "Claude 3.7 Sonnet (Databricks)", + family: "claude-sonnet", attachment: true, - reasoning: false, + reasoning: true, tool_call: true, temperature: true, - release_date: "2025-07-18", + release_date: "2025-02-24", modalities: { input: ["text", "image"], output: ["text"] }, - cost: { input: 0.15, output: 0.6, cache_read: 0.075 }, - limit: { context: 128000, output: 16384 }, + cost: { input: 3, output: 15, cache_read: 0.3 }, + limit: { context: 200000, output: 64000 }, options: {}, }, - "databricks-gpt-5-nano": { - id: "databricks-gpt-5-nano", - name: "GPT-5 Nano (Databricks)", - family: "gpt-5", + "databricks-claude-opus-4-1": { + id: "databricks-claude-opus-4-1", + name: "Claude Opus 4.1 (Databricks)", + family: "claude-opus", attachment: true, - reasoning: false, + reasoning: true, tool_call: true, temperature: true, - release_date: "2025-07-18", + release_date: "2025-04-16", modalities: { input: ["text", "image"], output: ["text"] }, - cost: { input: 0.075, output: 0.3, cache_read: 0.0375 }, - limit: { context: 128000, output: 16384 }, + cost: { input: 15, output: 75, cache_read: 1.5 }, + limit: { context: 200000, output: 32000 }, options: {}, }, - "databricks-gemini-2-5-flash": { - id: "databricks-gemini-2-5-flash", - name: "Gemini 2.5 Flash (Databricks)", - family: "gemini-2.5", - attachment: true, - reasoning: true, + "databricks-llama-4-maverick": { + id: "databricks-llama-4-maverick", + name: "Llama 4 Maverick (Databricks)", + family: "llama-4", + attachment: false, + reasoning: false, tool_call: true, temperature: true, - release_date: "2025-04-17", - modalities: { input: ["text", "image", "audio", "video"], output: ["text"] }, - cost: { input: 0.15, output: 0.6, cache_read: 0.0375 }, + release_date: "2025-04-05", + modalities: { input: ["text"], output: ["text"] }, + cost: { input: 0.2, output: 0.6 }, limit: { context: 1048576, output: 65536 }, options: {}, }, - "databricks-gemini-3-flash": { - id: "databricks-gemini-3-flash", - name: "Gemini 3 Flash (Databricks)", - family: "gemini-3", - attachment: true, - reasoning: true, + "databricks-meta-llama-3-1-405b-instruct": { + id: "databricks-meta-llama-3-1-405b-instruct", + name: "Meta Llama 3.1 405B Instruct (Databricks)", + family: "llama-3.1", + attachment: false, + reasoning: false, tool_call: true, temperature: true, - release_date: "2025-12-11", - modalities: { input: ["text", "image", "audio", "video"], output: ["text"] }, - cost: { input: 0.1, output: 0.4, cache_read: 0.025 }, - limit: { context: 1048576, output: 65536 }, + release_date: "2024-07-23", + modalities: { input: ["text"], output: ["text"] }, + cost: { input: 3, output: 3 }, + limit: { context: 128000, output: 4096 }, options: {}, }, - "databricks-gemini-3-pro": { - id: "databricks-gemini-3-pro", - name: "Gemini 3 Pro (Databricks)", - family: "gemini-3", - attachment: true, - reasoning: true, + "databricks-meta-llama-3-1-8b-instruct": { + id: "databricks-meta-llama-3-1-8b-instruct", + name: "Meta Llama 3.1 8B Instruct (Databricks)", + family: "llama-3.1", + attachment: false, + reasoning: false, tool_call: true, temperature: true, - release_date: "2025-12-11", - modalities: { input: ["text", "image", "audio", "video"], output: ["text"] }, - cost: { input: 1.25, output: 10, cache_read: 0.3125 }, - limit: { context: 2097152, output: 65536 }, + release_date: "2024-07-23", + modalities: { input: ["text"], output: ["text"] }, + cost: { input: 0.1, output: 0.1 }, + limit: { context: 128000, output: 4096 }, options: {}, }, } diff --git a/packages/opencode/test/provider/databricks.test.ts b/packages/opencode/test/provider/databricks.test.ts index 7b935fd35b8..db4b3228a1f 100644 --- a/packages/opencode/test/provider/databricks.test.ts +++ b/packages/opencode/test/provider/databricks.test.ts @@ -400,6 +400,12 @@ test("Databricks: model capabilities are set correctly", async () => { expect(claudeModel).toBeDefined() expect(claudeModel.capabilities.toolcall).toBe(true) expect(claudeModel.capabilities.attachment).toBe(true) + + // Check Llama model capabilities + const llamaModel = providers["databricks"].models["databricks-llama-4-maverick"] + expect(llamaModel).toBeDefined() + expect(llamaModel.capabilities.toolcall).toBe(true) + expect(llamaModel.capabilities.attachment).toBe(false) // Llama doesn't support images }, }) }) From ac24bd29d6e020167fc8d01649f2a3ab255cb883 Mon Sep 17 00:00:00 2001 From: dgokeeffe Date: Mon, 12 Jan 2026 23:11:25 +1100 Subject: [PATCH 3/3] fix(opencode): Transform Databricks models to Provider.Model format The default models were being added as ModelsDev.Model objects directly, but input.models expects Provider.Model objects with capabilities, api, and other transformed properties. Add toProviderModel() helper to properly transform models, matching the fromModelsDevModel() pattern used elsewhere. --- packages/opencode/src/provider/provider.ts | 55 +++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index b6c1c18e3b1..8e1b30d0c5e 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -702,10 +702,63 @@ export namespace Provider { }, } + // Transform ModelsDev.Model to Provider.Model format + function toProviderModel(model: ModelsDev.Model): Model { + return { + id: model.id, + providerID: "databricks", + name: model.name, + family: model.family, + api: { + id: model.id, + url: baseURL, + npm: "@ai-sdk/openai-compatible", + }, + status: "active", + headers: {}, + options: model.options ?? {}, + cost: { + input: model.cost?.input ?? 0, + output: model.cost?.output ?? 0, + cache: { + read: model.cost?.cache_read ?? 0, + write: model.cost?.cache_write ?? 0, + }, + }, + limit: { + context: model.limit.context, + output: model.limit.output, + }, + capabilities: { + temperature: model.temperature, + reasoning: model.reasoning, + attachment: model.attachment, + toolcall: model.tool_call, + input: { + text: model.modalities?.input?.includes("text") ?? false, + audio: model.modalities?.input?.includes("audio") ?? false, + image: model.modalities?.input?.includes("image") ?? false, + video: model.modalities?.input?.includes("video") ?? false, + pdf: model.modalities?.input?.includes("pdf") ?? false, + }, + output: { + text: model.modalities?.output?.includes("text") ?? false, + audio: model.modalities?.output?.includes("audio") ?? false, + image: model.modalities?.output?.includes("image") ?? false, + video: model.modalities?.output?.includes("video") ?? false, + pdf: model.modalities?.output?.includes("pdf") ?? false, + }, + interleaved: false, + }, + release_date: model.release_date, + variants: {}, + } + } + // Add default models to the input provider if not already defined for (const [modelID, model] of Object.entries(defaultModels)) { if (!input.models[modelID]) { - input.models[modelID] = model + input.models[modelID] = toProviderModel(model) } }