diff --git a/.gitignore b/.gitignore index 19198a7a5918..cfbff6c35058 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ UPCOMING_CHANGELOG.md logs/ *.bun-build tsconfig.tsbuildinfo + +# Auto-generated by build.ts; do not commit +/packages/opencode/src/provider/models-snapshot.ts diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e4f87a794355..20052eb36f17 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -178,7 +178,9 @@ "xdg-basedir": "5.1.0", "yargs": "18.0.0", "zod": "catalog:", - "zod-to-json-schema": "3.24.5" + "zod-to-json-schema": "3.24.5", + "@databricks/ai-sdk-provider": "0.5.0", + "@databricks/sdk-experimental": "0.16.0" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/opencode/script/test-databricks-3-classes.ts b/packages/opencode/script/test-databricks-3-classes.ts new file mode 100644 index 000000000000..5dd044f4e954 --- /dev/null +++ b/packages/opencode/script/test-databricks-3-classes.ts @@ -0,0 +1,151 @@ +#!/usr/bin/env bun +// @ts-nocheck +/** + * E2E test: verify all three classes of major foundation models on Databricks + * Model Serving can handle a tool call. + * + * Usage: + * DATABRICKS_CONFIG_PROFILE=logfood bun run script/test-databricks-3-classes.ts + * bun run script/test-databricks-3-classes.ts --profile logfood + */ +import { generateText, tool, jsonSchema } from "ai" + +const profile = process.argv.includes("--profile") + ? process.argv[process.argv.indexOf("--profile") + 1] + : process.env["DATABRICKS_CONFIG_PROFILE"] + +const TARGETS: Array<{ class: string; match: (n: string) => boolean; forceResponses?: boolean }> = [ + { class: "Claude (Anthropic)", match: (n: string) => n === "databricks-claude-sonnet-4-6" }, + // GPT-5.x on Databricks requires Responses API for tool use even though the + // serving-endpoint metadata reports task=llm/v1/chat. The proxy returns 400 + // on /v1/chat/completions: "Function tools with reasoning_effort are not + // supported for gpt-5.5 in /v1/chat/completions. Please use /v1/responses". + { class: "GPT (OpenAI)", match: (n: string) => n === "databricks-gpt-5-5", forceResponses: true }, + { class: "Gemini (Google)", match: (n: string) => n === "databricks-gemini-2-5-pro" }, +] + +console.log(`=== Databricks 3-class E2E (profile: ${profile ?? "env"}) ===\n`) + +const { Config: DatabricksConfig, WorkspaceClient } = await import("@databricks/sdk-experimental") + +const env = { ...process.env } +if (profile) { + delete env.DATABRICKS_HOST + delete env.DATABRICKS_TOKEN +} + +const dbConfig = new DatabricksConfig({ env, profile }) +await dbConfig.ensureResolved() +const host = (await dbConfig.getHost()).origin +console.log(`Host: ${host}\n`) + +const ws = new WorkspaceClient(dbConfig) +const endpoints = [] +for await (const ep of ws.servingEndpoints.list()) { + if (ep.state?.ready === "READY") endpoints.push(ep) +} + +const { createOpenAI } = await import("@ai-sdk/openai") + +const databricksFetch: typeof globalThis.fetch = async (url, init) => { + const h = new Headers(init?.headers) + await dbConfig.authenticate(h) + if (init?.body && typeof init.body === "string") { + try { + const body = JSON.parse(init.body) + if (Array.isArray(body.tools)) { + for (const t of body.tools) { + const params = t.function?.parameters + if (params && !params.type) params.type = "object" + } + init = { ...init, body: JSON.stringify(body) } + } + } catch {} + } + return fetch(url, { ...init, headers: h }) +} + +const provider = createOpenAI({ + baseURL: `${host}/serving-endpoints`, + apiKey: "databricks", + fetch: databricksFetch, +}) + +type Result = { class: string; model: string; pass: boolean; reason?: string; toolArgs?: unknown } +const results: Result[] = [] + +for (const target of TARGETS) { + const ep = endpoints.find((e) => target.match(e.name ?? "")) + if (!ep) { + console.log(`[${target.class}] SKIP — no matching endpoint on this workspace`) + results.push({ class: target.class, model: "(none)", pass: false, reason: "endpoint not found" }) + continue + } + + console.log(`[${target.class}] Testing: ${ep.name} (${ep.task})`) + + const isResponses = ep.task === "llm/v1/responses" || target.forceResponses + const model = isResponses ? provider.responses(ep.name!) : provider.chat(ep.name!) + + const callPromise = generateText({ + model, + maxTokens: 300, + tools: { + get_weather: tool({ + description: "Get the current weather for a location", + inputSchema: jsonSchema<{ location: string }>({ + type: "object", + properties: { location: { type: "string", description: "City name" } }, + required: ["location"], + additionalProperties: false, + }), + execute: async ({ location }: { location: string }) => { + console.log(` [tool] get_weather({ location: "${location}" })`) + return { temperature: 22, condition: "sunny", location } + }, + }), + }, + maxSteps: 3, + messages: [{ role: "user", content: "What is the weather in Melbourne? Use the get_weather tool." }], + }) + + let res: Awaited | null = null + let err: Error | null = null + try { + res = await callPromise + } catch (e) { + err = e as Error + } + + if (err) { + console.log(` FAIL — ${err.message?.slice(0, 200)}\n`) + results.push({ class: target.class, model: ep.name!, pass: false, reason: err.message?.slice(0, 200) }) + continue + } + + const toolCalls = res!.steps.flatMap((s) => s.toolCalls) + const toolResults = res!.steps.flatMap((s) => s.toolResults) + const toolArgs = (toolCalls[0] as any)?.input + const pass = toolCalls.length > 0 && toolResults.length > 0 && !!toolArgs?.location + + console.log(` steps=${res!.steps.length} tool_calls=${toolCalls.length} tool_results=${toolResults.length} finish=${res!.finishReason}`) + if (toolArgs) console.log(` args: ${JSON.stringify(toolArgs)}`) + if (res!.text) console.log(` text: ${res!.text.slice(0, 120)}`) + console.log(` ${pass ? "PASS" : "FAIL"}\n`) + + results.push({ + class: target.class, + model: ep.name!, + pass, + toolArgs, + reason: pass ? undefined : `tool_calls=${toolCalls.length} tool_results=${toolResults.length} args=${toolArgs ? "yes" : "no"}`, + }) +} + +console.log("=== Summary ===") +for (const r of results) { + console.log(` ${r.pass ? "PASS" : "FAIL"} ${r.class.padEnd(20)} ${r.model}${r.reason ? " — " + r.reason : ""}`) +} + +const allPass = results.every((r) => r.pass) +process.exit(allPass ? 0 : 1) diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 3d6a0d91d0d1..89023a3d1057 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -32,7 +32,12 @@ export class WellKnown extends Schema.Class("WellKnownAuth")({ token: Schema.String, }) {} -const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" }) +export class DatabricksProfile extends Schema.Class("DatabricksProfileAuth")({ + type: Schema.Literal("databricks-profile"), + profile: Schema.String, +}) {} + +const _Info = Schema.Union([Oauth, Api, WellKnown, DatabricksProfile]).annotate({ discriminator: "type", identifier: "Auth" }) export const Info = Object.assign(_Info, { zod: zod(_Info) }) export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 4013dcee36e7..5a29c9986806 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -830,6 +830,611 @@ function custom(dep: CustomDep): Record { }, }, }), + databricks: Effect.fnUntraced(function* (input: Info) { + const { + Config: DatabricksConfig, + isAnyAuthConfigured, + WorkspaceClient, + } = yield* Effect.promise(() => import("@databricks/sdk-experimental")) + + const opencodeConfig = yield* dep.config() + const providerConfig = opencodeConfig.provider?.["databricks"] + const auth = yield* dep.auth("databricks") + const env = yield* dep.env() + const hasEnvAuth = !!(env.DATABRICKS_HOST && env.DATABRICKS_TOKEN) + const authProfile = + !hasEnvAuth && auth?.type === "databricks-profile" ? (auth as any).profile : undefined + const profile = hasEnvAuth + ? undefined + : (authProfile ?? providerConfig?.options?.profile) + + const envCopy = { ...env } + if (profile) { + delete envCopy.DATABRICKS_HOST + delete envCopy.DATABRICKS_TOKEN + } + const dbConfig = new DatabricksConfig({ + env: envCopy, + host: + (auth?.type === "api" ? auth.metadata?.host : undefined) ?? + providerConfig?.options?.baseURL ?? + providerConfig?.options?.host ?? + undefined, + token: auth?.type === "api" ? auth.key : undefined, + clientId: providerConfig?.options?.clientId, + clientSecret: providerConfig?.options?.clientSecret, + azureClientId: providerConfig?.options?.azureClientId, + azureClientSecret: providerConfig?.options?.azureClientSecret, + azureTenantId: providerConfig?.options?.azureTenantId, + profile, + }) + + const source = profile + ? `profile "${profile}"` + : dbConfig.host + ? `host ${dbConfig.host}` + : "default config" + + const resolveResult = yield* Effect.promise(async () => { + try { + await dbConfig.ensureResolved() + return { ok: true as const } + } catch (e) { + return { ok: false as const, error: e instanceof Error ? e.message : "unknown error" } + } + }) + if (!resolveResult.ok) { + log.warn(`Databricks auth failed to resolve (${source}): ${resolveResult.error}`) + return { autoload: false } + } + + if (!dbConfig.host || !isAnyAuthConfigured(dbConfig)) { + log.warn( + !dbConfig.host + ? "Databricks: no host configured" + : `Databricks: no auth for ${dbConfig.host}`, + ) + return { autoload: false } + } + + const authResult = yield* Effect.promise(async () => { + try { + const testHeaders = new Headers() + await dbConfig.authenticate(testHeaders) + if (!testHeaders.has("Authorization")) { + return { ok: false as const, reason: "no-credentials" as const } + } + return { ok: true as const } + } catch (e) { + return { ok: false as const, reason: "error" as const, error: e instanceof Error ? e.message : "unknown" } + } + }) + if (!authResult.ok) { + if (authResult.reason === "no-credentials") { + log.warn(`Databricks: auth produced no credentials for ${dbConfig.host}`) + } else { + log.warn(`Databricks: auth failed for ${dbConfig.host}: ${authResult.error}`) + } + return { autoload: false } + } + + const hostResult = yield* Effect.promise(async () => { + try { + return { ok: true as const, host: (await dbConfig.getHost()).origin } + } catch (e) { + return { ok: false as const, error: e instanceof Error ? e.message : "unknown" } + } + }) + if (!hostResult.ok) { + log.warn(`Databricks: getHost failed: ${hostResult.error}`) + return { autoload: false } + } + const normalizedHost = hostResult.host + + // Surface selection. Default is "auto" — probe AI Gateway, fall back to + // /serving-endpoints if disabled. AI Gateway is the strategic Databricks + // surface; the per-family adapters (@ai-sdk/anthropic, /google, /openai) + // hit it natively, bypassing @databricks/ai-sdk-provider entirely. + const surfacePref: "auto" | "ai-gateway" | "model-serving" = + providerConfig?.options?.surface ?? "auto" + const useAiGateway = yield* Effect.promise(async () => { + if (surfacePref === "model-serving") return false + try { + const headers = new Headers({ accept: "application/json" }) + await dbConfig.authenticate(headers) + const r = await fetch(`${normalizedHost}/ai-gateway/anthropic/v1/models`, { headers }) + if (r.ok) return true + if (surfacePref === "ai-gateway") { + log.warn(`Databricks: surface forced to ai-gateway but probe returned ${r.status}; using it anyway`) + return true + } + return false + } catch (e) { + if (surfacePref === "ai-gateway") { + log.warn(`Databricks: surface forced to ai-gateway but probe threw: ${e instanceof Error ? e.message : "unknown"}`) + return true + } + return false + } + }) + log.info("Databricks surface", { surface: useAiGateway ? "ai-gateway" : "model-serving", host: normalizedHost }) + + const baseURL = useAiGateway + ? `${normalizedHost}/ai-gateway` + : `${normalizedHost}/serving-endpoints` + + // Diagnostic switch: when DATABRICKS_BARE_FETCH=1, perform auth only and + // skip every outgoing body / incoming SSE workaround. Used to A/B which + // quirks AI Gateway still requires vs which the bundled provider hides. + const bareFetch = process.env.DATABRICKS_BARE_FETCH === "1" + + const databricksFetch = async (url: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers) + await dbConfig.authenticate(headers) + + if (!bareFetch && init?.body && typeof init.body === "string") { + try { + const body = JSON.parse(init.body) + if (Array.isArray(body.tools)) { + let modified = false + for (const t of body.tools) { + const params = t.function?.parameters + if (params && !params.type) { + params.type = "object" + modified = true + } + } + if (modified) init = { ...init, body: JSON.stringify(body) } + } + } catch {} + } + + const urlStr = typeof url === "string" ? url : url.toString() + const isResponsesApi = !bareFetch && urlStr.includes("/responses") + + const response = await fetch(url, { ...init, headers }) + + if ( + isResponsesApi && + response.body && + response.headers.get("content-type")?.includes("text/event-stream") + ) { + const decoder = new TextDecoder() + const encoder = new TextEncoder() + let buffer = "" + // State that must persist across `transform` invocations (one per + // network chunk batch), not be reset on every call: + // - `itemIdByOutputIndex` accumulates across the whole stream so + // content_part.added arriving in a later chunk than its + // output_item.added still gets rewritten correctly. + const itemIdByOutputIndex: Record = {} + + const transform = new TransformStream({ + transform(value: Uint8Array, controller) { + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split("\n") + buffer = lines.pop()! + + // Databricks emits Responses item IDs up to ~192 chars; OpenAI's + // Responses backend (and our consumer SDK) cap at 64. Truncate + // any id/item_id/call_id field deterministically so cross-event + // correlation still works (same long id → same 64-char prefix). + const truncateIds = (o: any): any => { + if (Array.isArray(o)) { + for (let i = 0; i < o.length; i++) o[i] = truncateIds(o[i]) + return o + } + if (o && typeof o === "object") { + for (const k of Object.keys(o)) { + const v = o[k] + if ( + typeof v === "string" && + v.length > 64 && + (k === "id" || k === "item_id" || k === "call_id") + ) { + o[k] = v.slice(0, 64) + } else { + o[k] = truncateIds(v) + } + } + } + return o + } + + // AI Gateway sometimes emits `response.output_item.added` with + // one item id (e.g. msg_073e...) and the subsequent + // `response.content_part.added` / `response.output_text.delta` + // events for the SAME output_index with a different item_id + // (e.g. msg_0177...). @ai-sdk/openai correlates text parts by + // id, so the mismatch breaks the stream with "text part not + // found". The map (declared in the outer scope) accumulates + // item ids per output_index across all chunk batches so + // dependent events arriving in later batches are rewritten. + + for (const line of lines) { + if (line.startsWith("data: ") && line !== "data: [DONE]") { + try { + const chunk = JSON.parse(line.slice(6)) + truncateIds(chunk) + if ( + chunk.type === "response.output_item.added" && + typeof chunk.output_index === "number" && + typeof chunk.item?.id === "string" + ) { + itemIdByOutputIndex[chunk.output_index] = chunk.item.id + } else if ( + typeof chunk.output_index === "number" && + typeof chunk.item_id === "string" && + itemIdByOutputIndex[chunk.output_index] != null && + itemIdByOutputIndex[chunk.output_index] !== chunk.item_id + ) { + chunk.item_id = itemIdByOutputIndex[chunk.output_index] + } + if (chunk.type === "response.completed") { + chunk.type = "responses.completed" + if (chunk.response?.usage && chunk.response.usage.total_tokens == null) { + chunk.response.usage.total_tokens = + (chunk.response.usage.input_tokens ?? 0) + + (chunk.response.usage.output_tokens ?? 0) + } + } + controller.enqueue(encoder.encode("data: " + JSON.stringify(chunk) + "\n")) + } catch { + controller.enqueue(encoder.encode(line + "\n")) + } + } else if (line.startsWith("event: response.completed")) { + controller.enqueue(encoder.encode("event: responses.completed\n")) + } else { + controller.enqueue(encoder.encode(line + "\n")) + } + } + }, + flush(controller) { + if (buffer.length > 0) { + controller.enqueue(encoder.encode(buffer)) + } + }, + }) + + return new Response(response.body.pipeThrough(transform), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) + } + + return response + } + + function toProviderModel(model: ModelsDev.Model): Model { + return { + id: model.id as any, + providerID: "databricks" as any, + name: model.name, + family: model.family, + api: { + id: model.id, + url: baseURL, + npm: "@databricks/ai-sdk-provider", + }, + status: "active", + headers: {}, + options: (model as any).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: {}, + } + } + + const DISCOVERY_TIMEOUT_MS = 10_000 + yield* Effect.promise(async () => { + try { + const client = new WorkspaceClient(dbConfig) + + await Promise.race([ + (async () => { + const toolCapableFamilies = [ + "claude", + "codex", + "gpt", + "gemini", + "llama", + "qwen", + "gemma", + ] + + const familyDefaults: Record< + string, + { + context: number + output: number + reasoning: boolean + attachment: boolean + maxTools?: number + } + > = { + claude: { context: 200000, output: 64000, reasoning: true, attachment: true }, + gpt: { context: 400000, output: 128000, reasoning: true, attachment: true, maxTools: 16 }, + codex: { + context: 400000, + output: 128000, + reasoning: true, + attachment: true, + maxTools: 10, + }, + gemini: { context: 1000000, output: 65536, reasoning: true, attachment: true }, + llama: { + context: 128000, + output: 8192, + reasoning: false, + attachment: false, + maxTools: 32, + }, + qwen: { + context: 128000, + output: 16000, + reasoning: false, + attachment: false, + maxTools: 32, + }, + gemma: { + context: 128000, + output: 8192, + reasoning: false, + attachment: false, + maxTools: 32, + }, + } + + for await (const endpoint of client.servingEndpoints.list()) { + const endpointName = endpoint.name + if (!endpointName) continue + const task = endpoint.task + + if (input.models[endpointName]) { + const configNameLower = endpointName.toLowerCase() + const configFamily = toolCapableFamilies.find((f) => + configNameLower.includes(f), + ) + const configNativeApiSurface = (() => { + switch (configFamily) { + case "claude": + return "anthropic" as const + case "gemini": + return "gemini" as const + case "gpt": + case "codex": + return "openai-responses" as const + default: + return "chat-completions" as const + } + })() + const npm = + configNativeApiSurface === "anthropic" + ? "@ai-sdk/anthropic" + : configNativeApiSurface === "gemini" + ? "@ai-sdk/google" + : configNativeApiSurface === "openai-responses" && useAiGateway + ? "@ai-sdk/openai" + : "@databricks/ai-sdk-provider" + input.models[endpointName].api = { + ...input.models[endpointName].api, + npm, + } + const configResponses = + configNativeApiSurface === "openai-responses" && + (task === "llm/v1/responses" || configFamily === "gpt") + input.models[endpointName].options = { + ...input.models[endpointName].options, + nativeApiSurface: configNativeApiSurface, + ...(configResponses ? { useResponsesApi: true } : {}), + } + continue + } + + const foundationModel = + endpoint.config?.served_entities?.[0]?.foundation_model + if (!foundationModel) continue + if (task && task !== "llm/v1/chat" && task !== "llm/v1/responses") continue + + const nameLower = endpointName.toLowerCase() + const family = toolCapableFamilies.find((f) => nameLower.includes(f)) + if (!family) { + log.info("Skipping endpoint - unknown model family", { + endpoint: endpointName, + }) + continue + } + + const familyDefault = familyDefaults[family] ?? { + context: 128000, + output: 16000, + reasoning: false, + attachment: false, + } + // Quirk #8 (per-endpoint active-tools cap) is enforced on + // /serving-endpoints but NOT on AI Gateway — verified empirically + // 2026-05-06 (script/probe-aigw-quirks.ts: 89 tools accepted on + // Claude/GPT/Gemini). On the gateway path drop the gpt-family + // cap that was added for the model-serving 89-tool rejection. + const defaults = + useAiGateway && (family === "gpt" || family === "codex") + ? { ...familyDefault, maxTools: undefined } + : familyDefault + + const nativeApiSurface = (() => { + switch (family) { + case "claude": + return "anthropic" as const + case "gemini": + return "gemini" as const + case "gpt": + case "codex": + return "openai-responses" as const + default: + return "chat-completions" as const + } + })() + + const useResponsesApi = + nativeApiSurface === "openai-responses" && + (task === "llm/v1/responses" || family === "gpt") + + const discoveredModel = { + id: endpointName, + name: foundationModel.display_name ?? endpointName, + family, + attachment: defaults.attachment, + reasoning: defaults.reasoning, + tool_call: true, + temperature: true, + release_date: new Date().toISOString().split("T")[0], + modalities: { + input: defaults.attachment ? ["text", "image"] : ["text"], + output: ["text"], + }, + cost: { input: 0, output: 0 }, + limit: { context: defaults.context, output: defaults.output }, + options: { + nativeApiSurface, + ...(useResponsesApi ? { useResponsesApi: true } : {}), + ...(defaults.maxTools ? { maxTools: defaults.maxTools } : {}), + }, + } as any as ModelsDev.Model + + const model = toProviderModel(discoveredModel) + if (nativeApiSurface === "anthropic") + model.api = { ...model.api, npm: "@ai-sdk/anthropic" } + else if (nativeApiSurface === "gemini") + model.api = { ...model.api, npm: "@ai-sdk/google" } + else if (nativeApiSurface === "openai-responses" && useAiGateway) + model.api = { ...model.api, npm: "@ai-sdk/openai" } + input.models[endpointName] = model + } + })(), + new Promise((_, reject) => + setTimeout( + () => + reject( + new Error(`Discovery timed out after ${DISCOVERY_TIMEOUT_MS}ms`), + ), + DISCOVERY_TIMEOUT_MS, + ), + ), + ]) + } catch (e) { + log.warn("Failed to discover Databricks serving endpoints", { + error: e instanceof Error ? e.message : "Unknown error", + }) + } + }) + + log.info("Databricks authenticated", { host: normalizedHost, profile }) + + const anthropicBase = useAiGateway + ? `${normalizedHost}/ai-gateway/anthropic/v1` + : `${normalizedHost}/serving-endpoints/anthropic/v1` + const geminiBase = useAiGateway + ? `${normalizedHost}/ai-gateway/gemini/v1beta` + : `${normalizedHost}/serving-endpoints/gemini/v1beta` + const openaiResponsesBase = useAiGateway + ? `${normalizedHost}/ai-gateway/codex/v1` + : null // model-serving path uses the bundled provider, not @ai-sdk/openai + + let anthropicSdk: any = null + let geminiSdk: any = null + let openaiSdk: any = null + + return { + autoload: true, + async getModel(_sdk: any, modelID: string, options?: Record) { + const surface = options?.nativeApiSurface + + if (surface === "anthropic") { + if (!anthropicSdk) { + const { createAnthropic } = await import("@ai-sdk/anthropic") + anthropicSdk = createAnthropic({ + baseURL: anthropicBase, + apiKey: "unused", + fetch: databricksFetch as typeof fetch, + headers: { "User-Agent": "opencode" }, + }) + } + return anthropicSdk(modelID) + } + + if (surface === "gemini") { + if (!geminiSdk) { + const { createGoogleGenerativeAI } = await import("@ai-sdk/google") + geminiSdk = createGoogleGenerativeAI({ + baseURL: geminiBase, + apiKey: "databricks", + fetch: databricksFetch as typeof fetch, + headers: { "User-Agent": "opencode" }, + }) + } + return geminiSdk(modelID) + } + + // GPT/Codex on AI Gateway: use @ai-sdk/openai's Responses adapter + // pointed at /ai-gateway/codex/v1. Bypasses @databricks/ai-sdk-provider + // entirely, so the lifecycle/flush bugs (#9, #10) don't apply on this + // path and the middleware in session/llm.ts is inert. + if (surface === "openai-responses" && openaiResponsesBase) { + if (!openaiSdk) { + const { createOpenAI } = await import("@ai-sdk/openai") + openaiSdk = createOpenAI({ + baseURL: openaiResponsesBase, + apiKey: "databricks", + fetch: databricksFetch as typeof fetch, + headers: { "User-Agent": "opencode" }, + }) + } + return openaiSdk.responses(modelID) + } + + if (options?.useResponsesApi) return _sdk.responses(modelID) + return _sdk.chatCompletions(modelID) + }, + options: { + baseURL, + headers: { "User-Agent": "opencode" }, + fetch: databricksFetch as typeof fetch, + }, + } + }), } } @@ -1152,7 +1757,9 @@ const layer: Layer.Layer< const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) provider.models = yield* Effect.promise(async () => { - const next = await models(provider, { auth: pluginAuth }) + // databricks-profile is an internal-only auth type and is never + // attached to a plugin-driven provider; safe to narrow here. + const next = await models(provider, { auth: pluginAuth as Parameters[1]["auth"] }) return Object.fromEntries( Object.entries(next).map(([id, model]) => [ id, diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index bd778dacc53e..0f05efccfe53 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -336,6 +336,39 @@ function normalizeMessages( }) } + // Databricks generates itemIds up to ~192 chars on its Responses path, but + // OpenAI's Responses backend validates incoming itemIds at max 64 chars. + // Strip any oversized itemId so it isn't echoed back and rejected on + // multi-turn. Applies to both the model-serving path (via the bundled + // provider's providerOptions.databricks.itemId) and the AI Gateway path + // (via @ai-sdk/openai's providerOptions.openai.itemId). + const nativeApiSurface = (model as any).options?.nativeApiSurface + const skipItemIdTruncation = + nativeApiSurface === "anthropic" || + nativeApiSurface === "gemini" || + process.env["DATABRICKS_BARE_FETCH"] === "1" + if ((model as any).options?.useResponsesApi && !skipItemIdTruncation) { + msgs = msgs.map((msg) => { + if (msg.role !== "assistant" || !Array.isArray(msg.content)) return msg + return { + ...msg, + content: msg.content.map((part) => { + const opts = (part as any).providerOptions + if (!opts) return part + let next = opts + for (const key of ["databricks", "openai"] as const) { + const itemId = next?.[key]?.itemId + if (typeof itemId === "string" && itemId.length > 64) { + const { itemId: _, ...rest } = next[key] + next = { ...next, [key]: rest } + } + } + return next === opts ? part : { ...part, providerOptions: next } + }) as typeof msg.content, + } + }) + } + return msgs } diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index c7990d1b3539..7d3a2feb8bcf 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -402,6 +402,66 @@ const live: Layer.Layer< return args.params }, }, + // @databricks/ai-sdk-provider emits only an atomic tool-call (no + // tool-input-start/delta/end) and re-emits each call in flush() + // with providerExecuted:true. Without this middleware, AI SDK + // skips local execution and the session processor never persists + // the tool part (it's created on tool-input-start), so every loop + // round prompts the model with no tool history. Synthesize the + // missing start chunk, dedupe the flush re-emit, and unwrap the + // "databricks-tool-call" placeholder back to its real name. + // + // DELETE-WHEN: drop this middleware once @databricks/ai-sdk-provider + // ships a release that (a) emits the AI-SDK v3 tool-streaming + // lifecycle on the Responses path and (b) stops setting + // providerExecuted:true in flush() when useRemoteToolCalling is + // false. Currently broken in 0.5.0; track upstream for the fix. + // + // Gate on both useResponsesApi AND the bundled npm package — on + // the AI Gateway path, GPT goes through @ai-sdk/openai which emits + // the proper lifecycle natively, so synthesizing a tool-input-start + // would double-emit and break the stream. + ...(input.model.options?.useResponsesApi && + input.model.api?.npm === "@databricks/ai-sdk-provider" + ? [ + { + specificationVersion: "v3" as const, + async wrapStream({ doStream }: any) { + const result = await doStream() + const seenToolCallIds = new Set() + return { + ...result, + stream: result.stream.pipeThrough( + new TransformStream({ + transform(chunk: any, controller: any) { + if (chunk.type === "tool-call") { + if (chunk.toolCallId && seenToolCallIds.has(chunk.toolCallId)) return + if (chunk.toolCallId) seenToolCallIds.add(chunk.toolCallId) + let toolName = chunk.toolName + let providerMetadata = chunk.providerMetadata + if (toolName === "databricks-tool-call") { + const actual = providerMetadata?.databricks?.toolName + if (actual && typeof actual === "string") toolName = actual + } + controller.enqueue({ + type: "tool-input-start", + id: chunk.toolCallId, + toolName, + ...(chunk.providerExecuted != null ? { providerExecuted: chunk.providerExecuted } : {}), + ...(providerMetadata != null ? { providerMetadata } : {}), + }) + controller.enqueue({ ...chunk, toolName }) + return + } + controller.enqueue(chunk) + }, + }), + ), + } + }, + }, + ] + : []), ], }), experimental_telemetry: { diff --git a/test-databricks-3-classes.sh b/test-databricks-3-classes.sh new file mode 100755 index 000000000000..b50ae578a8a1 --- /dev/null +++ b/test-databricks-3-classes.sh @@ -0,0 +1,138 @@ +#!/bin/bash +# E2E test: run opencode itself against Databricks Model Serving for one +# representative of each major foundation-model class (Claude / GPT / Gemini) +# using the profile in opencode.json. Tests basic response + tool use. + +set -u +BASE_URL="http://localhost:4096" +PROVIDER="databricks" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +MODELS=( + "databricks-claude-sonnet-4-6" + "databricks-gpt-5-5" + "databricks-gemini-2-5-pro" +) + +PASSED=0 +FAILED=0 +ERRORS=() +SERVER_PID="" + +start_server() { + echo -n " Starting dev server... " + pkill -f "bun.*serve" 2>/dev/null + sleep 1 + cd "$SCRIPT_DIR" && bun dev serve > /tmp/opencode-test-server.log 2>&1 & + SERVER_PID=$! + for i in $(seq 1 30); do + if curl -s "$BASE_URL/session" | jq -e . > /dev/null 2>&1; then + echo "OK (pid: $SERVER_PID)" + return 0 + fi + sleep 1 + done + echo "FAIL (timeout)" + return 1 +} + +stop_server() { + pkill -f "bun.*serve" 2>/dev/null +} + +test_model() { + local model="$1" + echo "" + echo "============================================" + echo "Testing: $model" + echo "============================================" + + echo -n " Creating session... " + local sess + sess=$(curl -s -X POST "$BASE_URL/session" -H "Content-Type: application/json" -d '{"title":"test-'"$model"'"}') + local sid + sid=$(echo "$sess" | jq -r '.id // empty') + if [ -z "$sid" ]; then + echo "FAIL" + FAILED=$((FAILED + 1)); ERRORS+=("$model: session creation failed"); return 1 + fi + echo "$sid" + + echo -n " Basic response... " + local resp + resp=$(curl -s --max-time 120 -X POST "$BASE_URL/session/$sid/message" \ + -H "Content-Type: application/json" \ + -d '{"model":{"providerID":"'"$PROVIDER"'","modelID":"'"$model"'"},"parts":[{"type":"text","text":"What is 2+2? Answer with just the number."}]}') + local err + err=$(echo "$resp" | jq -r '.info.error.name // empty') + if [ -n "$err" ]; then + local msg + msg=$(echo "$resp" | jq -r '.info.error.data.message // empty' | head -c 200) + echo "FAIL ($err: $msg)" + FAILED=$((FAILED + 1)); ERRORS+=("$model: basic - $err - $msg"); return 1 + fi + local txt + txt=$(echo "$resp" | jq -r '[.parts[] | select(.type=="text") | .text] | join(" ")') + if [ -z "$txt" ] || [ "$txt" = "null" ]; then + echo "FAIL (no text)" + FAILED=$((FAILED + 1)); ERRORS+=("$model: no text in basic response"); return 1 + fi + local in_t out_t + in_t=$(echo "$resp" | jq '.info.tokens.input // 0') + out_t=$(echo "$resp" | jq '.info.tokens.output // 0') + echo "OK (\"${txt:0:50}\") [${in_t}/${out_t} tokens]" + + echo -n " Tool call... " + local tool_resp + tool_resp=$(curl -s --max-time 300 -X POST "$BASE_URL/session/$sid/message" \ + -H "Content-Type: application/json" \ + -d '{"model":{"providerID":"'"$PROVIDER"'","modelID":"'"$model"'"},"parts":[{"type":"text","text":"Use the read tool to read the file at /Users/david.okeeffe/Repos/opencode/opencode.json and tell me the value of provider.databricks.options.profile."}]}') + local terr + terr=$(echo "$tool_resp" | jq -r '.info.error.name // empty') + if [ -n "$terr" ]; then + local tmsg + tmsg=$(echo "$tool_resp" | jq -r '.info.error.data.message // empty' | head -c 200) + echo "FAIL ($terr: $tmsg)" + FAILED=$((FAILED + 1)); ERRORS+=("$model: tool - $terr - $tmsg"); return 1 + fi + local hist + hist=$(curl -s "$BASE_URL/session/$sid/message") + local n_tools + n_tools=$(echo "$hist" | jq '[.[] | .parts[] | select(.type=="tool")] | length') + local final_text + final_text=$(echo "$tool_resp" | jq -r '[.parts[] | select(.type=="text") | .text] | join(" ")' | head -c 200) + if [ "$n_tools" -gt 0 ]; then + local names + names=$(echo "$hist" | jq -r '[.[] | .parts[] | select(.type=="tool") | .tool] | unique | join(", ")') + echo "OK (tools: $names) text: \"${final_text:0:80}\"" + PASSED=$((PASSED + 1)) + else + echo "FAIL (no tool call) text: \"${final_text:0:80}\"" + FAILED=$((FAILED + 1)); ERRORS+=("$model: no tool call in tool test") + fi +} + +echo "=====================================================" +echo " Databricks 3-class opencode E2E" +echo "=====================================================" +echo " Profile: $(jq -r '.provider.databricks.options.profile' opencode.json)" +echo " Models: ${#MODELS[@]}" +echo "=====================================================" + +if ! curl -s "$BASE_URL/session" | jq -e . > /dev/null 2>&1; then + start_server || exit 1 +else + echo " Server already running" +fi + +for m in "${MODELS[@]}"; do test_model "$m"; done + +echo "" +echo "=====================================================" +echo " RESULTS: $PASSED / ${#MODELS[@]} passed" +echo "=====================================================" +if [ ${#ERRORS[@]} -gt 0 ]; then + echo " Failures:" + for e in "${ERRORS[@]}"; do echo " - $e"; done +fi +exit $FAILED