#7984 · @dgokeeffe · opened Jan 12, 2026 at 11:31 AM UTC · last updated Mar 21, 2026 at 4:14 AM UTC

feat(opencode): Add Databricks provider support

appfeat
64
+478335327 files

Score breakdown

Impact

9.0

Clarity

9.0

Urgency

4.0

Ease Of Review

4.0

Guidelines

9.0

Readiness

8.0

Size

0.0

Trust

5.0

Traction

10.0

Summary

This PR introduces comprehensive Databricks provider support to opencode, enabling connection to their Foundation Model APIs with dynamic model discovery and robust SDK authentication. It addresses issue #7983 with detailed architectural explanations and extensive unit testing.

Open in GitHub

Description

Summary

Adds Databricks Foundation Model APIs as a new provider, enabling opencode users to connect to their Databricks workspace's pay-per-token LLM endpoints.

Fixes #7983

Changes

  • Provider implementation (provider.ts): Full Databricks provider with per-model SDK routing (Claude -> Anthropic SDK, GPT/Codex -> OpenAI Responses API, Gemini/others -> OAI-compatible)
  • SDK auth (@databricks/sdk-experimental): Uses the official Databricks JS SDK for all authentication - supports PAT, OAuth, Azure AD, and all other SDK-supported auth methods automatically
  • Dynamic model discovery: All models are discovered dynamically via the Serving Endpoints API - no hardcoded model list. Assigns family defaults (context window, capabilities) based on model name pattern matching.
  • Codex support: Codex models are routed through the OpenAI Responses API (required by Databricks). A stream transformer normalizes Databricks' mismatched item IDs to be compatible with the AI SDK. OpenAI-specific features (encrypted reasoning content, reasoning summaries) are excluded.
  • Auth guidance (auth.ts, dialog-provider.tsx): Added Databricks to auth login flow with SDK-based credential detection
  • Test cleanup (preload.ts): Clear Databricks env vars between tests
  • Unit tests (databricks.test.ts): Tests covering config parsing, auth, URL handling, model capabilities, SDK routing, dynamic discovery, prompt caching, and Codex integration

Authentication

Uses @databricks/sdk-experimental for authentication, which supports all Databricks auth methods automatically:

  • PAT token via DATABRICKS_TOKEN
  • OAuth M2M via DATABRICKS_CLIENT_ID + DATABRICKS_CLIENT_SECRET
  • Azure AD, Databricks CLI profiles, and all other SDK-supported methods

The SDK handles credential resolution, token refresh, and header injection.

Model discovery

All models come from dynamic discovery via the Serving Endpoints API - there are no hardcoded model definitions. The provider:

  1. Lists all serving endpoints in the workspace
  2. Filters for Foundation Model API endpoints with chat task type
  3. Matches model names to known families (claude, gpt, codex, gemini) for capability defaults
  4. Only includes models from families known to reliably support tool calling

Users can add custom model endpoints via opencode.json to override or supplement discovered models.

Architecture

  • databricksFetch: Custom fetch wrapper that:
    • Injects SDK auth headers
    • Fixes empty content responses (Databricks rejects content: "")
    • Normalizes Responses API streams - Databricks' proxy uses different item IDs in output_item.added events vs content_part/output_text events. The transformer tracks IDs by output_index and rewrites mismatched item_id fields to be consistent.
    • Transforms Gemini streaming format (array content to string)
  • getModel: Routes each model to the appropriate AI SDK:
    • GPT/Codex -> @ai-sdk/openai Responses API (required for Codex)
    • Claude -> @ai-sdk/anthropic (native Anthropic API)
    • Others -> @ai-sdk/openai-compatible (chat completions)
  • toProviderModel: Detects model families to set correct capabilities (streaming, reasoning, prompt caching)
  • transform.ts: Databricks models skip OpenAI-specific provider options (encrypted reasoning content, reasoning summaries, previous_response_id) that the Databricks proxy doesn't support
  • Prompt caching enabled for Databricks-hosted Claude models via transform.ts integration

Verification

  • All 36 tests pass: bun test packages/opencode/test/provider/databricks.test.ts
  • Tested locally with PAT authentication against a Databricks workspace
  • Verified raw SSE stream from Databricks Responses API to confirm the ID mismatch root cause
  • Full typecheck and build pass (pre-push hook verified)

Linked Issues

#7983 Support for Databricks Foundation Model APIs provider

View issue

Comments

PR comments

cbcoutinho

@dgokeeffe I am really looking forward to this PR landing, especially after seeing your post on LinkedIn regarding running opencode on a cluster via databricks ssh .... Great work!

I'm interested in running this locally, although without a Databricks PAT if possible. Can you provide a comment regarding auth via azure-cli or databricks-cli?

mdlam92

<img width="1670" height="490" alt="2026-01-21-124311_1670x490_scrot" src="https://github.com/user-attachments/assets/4746c7a6-fc52-4934-b52e-b4494e4d63fa" />

is this PR supposed to make databricks show up as a provider in the /connect command?

i checked out your branch locally and built it and was trying to use it to use models in databricks but not sure if this implements all that

mdlam92

<img alt="2026-01-21-124311_1670x490_scrot" width="1670" height="490" src="https://private-user-images.githubusercontent.com/35846054/538780740-4746c7a6-fc52-4934-b52e-b4494e4d63fa.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NjkwMjg1NDAsIm5iZiI6MTc2OTAyODI0MCwicGF0aCI6Ii8zNTg0NjA1NC81Mzg3ODA3NDAtNDc0NmM3YTYtZmM1Mi00OTM0LWI1MmUtYjQ0OTRlNGQ2M2ZhLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNjAxMjElMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjYwMTIxVDIwNDQwMFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTNhZDI3ZDc3NmE5MThhODFiZGU1YTU3YjZkNzZlN2FlMmFmNWMwMTQ2NjZjNGJjMzE3OWZkMDRkMGMxZTQzZmYmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.GABf1tnmA1j82bKrThf1zq6W1BbboNvofN0GuyVpWqM"> is this PR supposed to make databricks show up as a provider in the `/connect` command?

i checked out your branch locally and built it and was trying to use it to use models in databricks but not sure if this implements all that

oh i got it working, i guess i had to manually add my provider to my opencode.json?? im not entirely sure why that worked tho

dgokeeffe

@mdlam92 - I pulled the trigger a bit too quick on this PR. As I tested it more and more, I had to make some changes. Databricks should now appear as a provider on the Connect screen.

There were a few changes I had to make that are in this PR:

  1. Empty content handling - The AI SDK sends content: "" for assistant messages that only have tool calls. Databricks Model Serving (which uses OpenAI-compatible endpoints) rejects these empty strings. This PR filters out empty content and transforms it to content: null where needed.
  2. Prompt caching - Added support for Databricks prompt caching via cache_control on system messages and recent conversation turns. This works for models that support it (GPT, Gemini, Claude via Databricks).
  3. Host URL prompt in Connect screen - Unlike other providers with fixed API endpoints, Databricks requires your workspace-specific URL (e.g., https://your-workspace.cloud.databricks.com). I added an extra prompt in the /connect flow to capture the host URL along with your API key.

Authentication options (in order of precedence):

  1. PAT Token - Set DATABRICKS_HOST and DATABRICKS_TOKEN environment variables
  2. Databricks CLI - Run databricks auth login, and the provider will use your cached token from ~/.databricks/token-cache.json
  3. Azure CLI (for Azure Databricks) - If you're logged in with az login, it will use that for workspaces on *.azuredatabricks.net
  4. OAuth M2M - Set DATABRICKS_HOST, DATABRICKS_CLIENT_ID, and DATABRICKS_CLIENT_SECRET

Quick start

Option 1: Using Databricks CLI (easiest)

databricks auth login --host https://your-workspace.cloud.databricks.com

Option 2: Using environment variables

export DATABRICKS_HOST="https://your-workspace.cloud.databricks.com"                                                                                                                                               
export DATABRICKS_TOKEN="your-pat-token" 

Once auth is configured, Databricks should auto-load with default models (Claude, GPT-5, Gemini). You shouldn't need to manually edit opencode.json unless you want to add custom model endpoints.

Let me know if you run into any issues, and anything I need to do to get this merged in.

hellomikelo

Just tested this PR and can confirm Databricks can be connected as a model provider.

But gpt 5.2 endpoint needs to change to use the /responses endpoint.

Bad Request: Model databricks-gpt-5-1-codex-max only supports the Responses API. Please use /serving-endpoints/responses instead.

elementalvoid

I'm super stoked to see this! I searched for such a provider a few weeks ago but it had to have been just before you created the issue. Last week I took a go at adding support, but I did so as a plugin where you decided to integrate into the CLI. Official in-built support will be great but I had no idea what I was getting myself into so I wanted to start a bit more out of band. I got a working version and was just publishing it internally to my company today for folks to try out when someone informed me of your PR!

I've a couple of thoughts/questions as I read and compare where I landed...

  1. I am spoiled by the GH Copilot provider autoloading available models so I chose to use a Databricks API (${workspace_host}/api/2.0/serving-endpoints) to enumerate the available models.
    • This meant my plugin picks up the foundational models plus any custom and external models automatically. This API provides detailed model information (cost, capabilities, etc.).
    • It meant that I only show available models whether that be due to permissions or other availability requirements (related to your comment that "These are the pay-per-token endpoints available in most workspaces").
    • Sadly, this API does not provide context window sizing so maybe that's reason enough to use hard coded model configs.
  2. Model costs: Since the models returned from (1) included costs in DBU units, I created a configurable dbu_rate so that if my DBU cost or rate was different than someone else's it could be adjusted. But I see you are already converting from DBUs to USD. Super transparently, I have no clue if their DBUs are static across users or not. Do you know?

I ran into some other issues (errors about stream_options, parallel tool calls, gemini thoughtSignatures, etc.) that lead me to do much more transformation than you did. I'm hoping that was due to my implementation. But I'll try to test your version out soon and see if I run into any similar issues.

elementalvoid

I got a chance to test last night. I'm excited to see this work happening but I had some pretty major troubles....

I'm going to detail my issues below. I'll post a second comment with a summary of all the translations that I had to make. Sorry/not-sorry about the amount of text that is about to appear.

I'm happy to participate however you find it most helpful. I'm unsure at the moment about making my provider plugin public but I can pursue that internally if it would help.


1. /connect and auth login don't work

Like @mdlam92, /connect (and opencode auth login) does not show the new provider. I exported the host/key env vars and it gets enabled automatically.

2. GPT Codex doesn't work

I can replicate @hellomikelo's databricks-gpt-5-1-codex-max issue.

❯ ./packages/opencode/dist/opencode-darwin-arm64/bin/opencode run --model databricks/databricks-gpt-5-1-codex-max 'hello'
Error: Bad Request: Model databricks-gpt-5-1-codex-max only supports the Responses API. Please use /serving-endpoints/responses instead.

I will note that non-codex gpt models work fine:

❯ ./packages/opencode/dist/opencode-darwin-arm64/bin/opencode run --model databricks/databricks-gpt-5-1 'hello'

Hi! What are you working on today, or how can I help with your code?

3. Model availability

Not all of the hard coded models are available. Notably the gemma, gpt-oss, and llama models are missing.

❯ ./packages/opencode/dist/opencode-darwin-arm64/bin/opencode models
databricks/databricks-claude-3-7-sonnet
databricks/databricks-claude-haiku-4-5
databricks/databricks-claude-opus-4-1
databricks/databricks-claude-opus-4-5
databricks/databricks-claude-sonnet-4
databricks/databricks-claude-sonnet-4-5
databricks/databricks-gemini-2-5-flash
databricks/databricks-gemini-2-5-pro
databricks/databricks-gemini-3-flash
databricks/databricks-gemini-3-pro
databricks/databricks-gpt-5
databricks/databricks-gpt-5-1
databricks/databricks-gpt-5-1-codex-max
databricks/databricks-gpt-5-2
databricks/databricks-gpt-5-mini
databricks/databricks-gpt-5-nano

4. Gemini models don't work

4a. MCP tool calls

When using a Databricks hosted Gemini model, MCP Tool calls need to have the JSON Schema sanitized to remove the $schema entry. Without this we get the following:

❯ ./packages/opencode/dist/opencode-darwin-arm64/bin/opencode run --model databricks/databricks-gemini-3-flash 'hello'
Error: Bad Request: {
  "error": {
    "code": 400,
    "message": "Invalid JSON payload received. Unknown name \"$schema\" at 'tools[0].function_declarations[0].parameters': Cannot find field.\nInvalid JSON payload received. Unknown name \"$schema\" at 'tools[0].function_declarations[1].parameters': Cannot find field.\nInvalid JSON payload received.",
    "status": "INVALID_ARGUMENT",
    "details": [
      {
        "@type": "type.googleapis.com/google.rpc.BadRequest",
        "fieldViolations": [
          {
            "field": "tools[0].function_declarations[0].parameters",
            "description": "Invalid JSON payload received. Unknown name \"$schema\" at 'tools[0].function_declarations[0].parameters': Cannot find field."
          },
          {
            "field": "tools[0].function_declarations[1].parameters",
            "description": "Invalid JSON payload received. Unknown name \"$schema\" at 'tools[0].function_declarations[1].parameters': Cannot find field."
          }
        ]
      }
    ]
  }
}

I condensed that to two tools but it is dependent on how many tools you have.

I have a set of changes that resolve this but they're very likely not the right way to resolve it as I sanitized both the gemini model requests and all mcp tool requests (for all models and providers .. gross).

4b. Response structure string vs. array

With MCPC resolved, requests with a gemini model now get a response but Databricks returns an array of object for the content instead of a string as expected:

❯ ./packages/opencode/dist/opencode-darwin-arm64/bin/opencode run --model databricks/databricks-gemini-3-flash 'hello'

Error: AI_TypeValidationError: Type validation failed: Value: {"model":"gemini-3-flash-preview","choices":[{"delta":{"role":"assistant","content":[{"type":"text","text":"Hello! How can I help you with your codebase today?","thoughtSignature":"CiEBjz1rXyr7BGOKA1RF3FdIrRxBsGEHM2WT7j+0CLfurTo="}]},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":18295,"completion_tokens":12,"total_tokens":18307},"object":"chat.completion.chunk","id":null,"created":1770142691}.
Error message: [{"code":"invalid_union","errors":[[{"expected":"string","code":"invalid_type","path":["choices",0,"delta","content"],"message":"Invalid input: expected string, received array"}],[{"expected":"object","code":"invalid_type","path":["error"],"message":"Invalid input: expected object, received undefined"}]],"path":[],"message":"Invalid input"}]

We need to transform the response's choices.content (returned as an array of object). We need to give back content as a string and deal with the thoughtSignature which is in the object. But we quickly spiral into other transformations too (see next comment).

elementalvoid

Regarding the rest of the transformations I had to make, I had Opus create the following summary of all of the specializations that I added. I honestly don't know if all of them are strictly required, but with these I found that all of my models worked without issue.

1. Gemini-Specific Transformations
Tool Schema Sanitization (utils/sanitize-tools.ts)

Gemini has strict JSON Schema requirements. The following sanitizations are applied:

  • Strips $schema field - Gemini doesn't support this standard JSON Schema field
  • Resolves $ref inline - Gemini poorly handles JSON Schema references, so they're inlined
  • Removes definitions/$defs - After inlining refs, these are deleted
  • Strips non-standard ref field - Some tools use ref (without $) which Gemini interprets incorrectly
  • Preserves only $ref, description, default when a ref is present
Tool Call History Handling (convert-to-openai-messages.ts:281-314)

Gemini has thought_signature requirements that break multi-turn tool conversations:

  • Historical tool calls → text - Converted to [Called tool: name(args)] format
  • Historical tool results → user messages - Become user messages with [Tool result for name: content] format
  • Only current-turn tool calls preserved - The most recent assistant message retains actual tool call structure
Thought Signature Preservation (databricks-chat-model.ts:471-556)

Gemini's thoughtSignature is extracted and passed through providerMetadata.databricks.thoughtSignature.

2. Stream Options Handling

Some models reject stream_options (databricks-chat-model.ts:232-237):

| Model Family | stream_options | |--------------|------------------| | Llama | Omitted | | Qwen | Omitted | | Gemma | Omitted | | GPT-OSS | Omitted | | Claude | Included | | Gemini | Included | | Other | Included |

3. Message Format Transformations
AI SDK → OpenAI-compatible format (convert-to-openai-messages.ts)

| Message Type | Transformation | |--------------|----------------| | System | Passed through directly | | User | Text and file parts converted; images formatted to data URLs | | Assistant | Text extracted, tool calls converted, empty content → null | | Tool | Result output converted to string (handles text, json, error-text, error-json types) |

Empty Content Normalization

When assistant has tool calls but no text, content becomes null (not empty string) - this is a Databricks requirement.

content: textContent || (toolCalls.length > 0 ? null : '')
4. Image Formatting (utils/format-image.ts)

Normalizes image data to proper URLs:

| Input Type | Output | |------------|--------| | URL objects | String URL | | Binary data (Uint8Array/ArrayBuffer) | Base64 data URL (chunked encoding to avoid stack overflow) | | String data URLs | Passed through | | HTTP/HTTPS URLs | Passed through | | Raw base64 string | Prefixed with data:{mediaType};base64, |

5. Finish Reason Mapping (map-openai-finish-reason.ts)

Maps OpenAI finish reasons to AI SDK format:

| OpenAI | AI SDK | |--------|--------| | stop | stop | | length | length | | content_filter | content-filter | | tool_calls | tool-calls | | function_call | tool-calls | | (other) | unknown |

6. Response Content Handling

Handles both response formats (databricks-chat-model.ts:291-309, 433-458):

  • String content - Directly used
  • Array of content parts - Text parts extracted and concatenated (for multi-modal responses)
Key Design Decisions
  1. parallel_tool_calls omitted - Databricks doesn't support this parameter
  2. No cache_control - Stripped from text parts (Anthropic-specific)
  3. Model-agnostic tool format - Converts to OpenAI function calling format universally

elementalvoid

I gave this another test this morning and most of what I've had issues with is resolved. Thanks again for your work on this!

Remaining issues that I experienced in testing:

  • Codex models need to use the responses endpoint
    Error: Bad Request: Model databricks-gpt-5-1-codex-max only supports the Responses API. Please use /serving-endpoints/responses instead.
    
  • Since Databricks is not listed on models.dev we don't get dynamic model availability updates; only the hard coded list which is already out of date. My account has Sonnet 4.6, Opus 4.6, GPT 5.2 Codex, and 5.1 Codex Mini available. But in order for me to use them I must hard code them to my config, and to do that I have to dig around in the Serving UI and docs to figure out the context windows, modality, etc. For custom models this of course makes sense but I would hope for a more dynamic way to autodiscover the foundational models. Honestly though I understand this is a bit of a non-functional ask so ... ¯\_(ツ)_/¯

dgokeeffe

Update: Squashed to a single commit and fixed Codex model support.

What changed since last update

  • Codex models now work via the Responses API. The root cause was that Databricks' Responses API proxy uses different item IDs in output_item.added events (short ID like msg_05cf...) vs content_part/output_text events (long ID like msg_01ad...). The AI SDK maps text-start from the first and looks up text-delta from the second — when they don't match, it errors with "text part msg_... not found". Fixed by adding a stream normalizer in databricksFetch that rewrites item_id in delta events to match the registered output_item.added ID.

  • Squashed 7 commits → 1 clean commit for easier review.

  • Transform guards added so Databricks GPT/Codex models don't request OpenAI-specific features (encrypted_content, reasoningSummary) that the proxy doesn't support.

CI status

The only failing test (tool.registry > loads tools with external dependencies without crashing) is a pre-existing upstream issue from #12227 — bun install --no-cache doesn't resolve the cowsay dependency in CI. All Databricks tests pass (36/36). Typecheck, compliance, standards, and nix-eval all pass.

Testing done

  • All 36 unit tests pass locally
  • Verified raw SSE stream from Databricks Responses API via curl to confirm the ID mismatch root cause
  • Tested with PAT auth against a live Databricks workspace
  • Full typecheck and build pass

@thdxr @fwang — would appreciate a review when you get a chance. Happy to make any changes needed.

Nozzie

What is the progress on this PR? I would love to be able to use Databricks as a model provider.

chiggly007

agree what is missing, here honestly opencode team this is literally lost revenue..

Review comments

elementalvoid

Looks like this works for (most?) built-in tools, but not MCP tools.

With context7 enabled:

❯ grep -A5 '"mcp":' .opencode/opencode.jsonc
  "mcp": {
    "context7": {
      "enabled": true,
      "type": "remote",
      "url": "https://mcp.context7.com/mcp",
    },

❯ ./packages/opencode/dist/opencode-darwin-arm64/bin/opencode run --model databricks/databricks-gemini-3-flash 'hello'
Error: Bad Request: {
  "error": {
    "code": 400,
    "message": "Invalid JSON payload received. Unknown name \"$schema\" at 'tools[0].function_declarations[31].parameters': Cannot find field.\nInvalid JSON payload received. Unknown name \"$schema\" at 'tools[0].function_declarations[32].parameters': Cannot find field.",
    "status": "INVALID_ARGUMENT",
    "details": [
      {
        "@type": "type.googleapis.com/google.rpc.BadRequest",
        "fieldViolations": [
          {
            "field": "tools[0].function_declarations[31].parameters",
            "description": "Invalid JSON payload received. Unknown name \"$schema\" at 'tools[0].function_declarations[31].parameters': Cannot find field."
          },
          {
            "field": "tools[0].function_declarations[32].parameters",
            "description": "Invalid JSON payload received. Unknown name \"$schema\" at 'tools[0].function_declarations[32].parameters': Cannot find field."
          }
        ]
      }
    ]
  }
}

If we disable context7 we get a fun new error on the builtin question tool:

❯ grep -A5 '"mcp":' .opencode/opencode.jsonc
  "mcp": {
    "context7": {
      "enabled": false,
      "type": "remote",
      "url": "https://mcp.context7.com/mcp",
    },


❯ ./packages/opencode/dist/opencode-darwin-arm64/bin/opencode run --model databricks/databricks-gemini-3-flash 'hello'
Error: Bad Request: {
  "error": {
    "code": 400,
    "message": "Schema.ref 'QuestionOption' was set alongside unsupported fields.  If a schema node has Schema.ref set, then only description and default can be set alongside it; other fields they would be replaced by the expanded reference.",
    "status": "INVALID_ARGUMENT"
  }
}

elementalvoid

I'm not sure the best way to populate this, but I think models here being empty causes this error when no auth is configured (not in auth.json and not via env var):

❯ env | grep -c DATABRICKS
0

❯ grep -ic databricks ~/.local/share/opencode/auth.json
0

❯ ./packages/opencode/dist/opencode-darwin-arm64/bin/opencode
{
  "name": "UnknownError",
  "data": {
    "message": "TypeError: undefined is not an object (evaluating 'Provider2.sort(Object.values(item.models))[0].id')\n    at <anonymous> (src/server/routes/provider.ts:68:93)\n    at o3 (../../node_modules/.bun/remeda@2.26.0/node_modules/remeda/dist/chunk-3ZJAREUD.js:1:137)\n    at <anonymous> (src/server/routes/provider.ts:68:20)\n    at processTicksAndRejections (native:7:39)"
  }
}

A similar error condition might exist in packages/opencode/src/provider/provider.ts where the databricks provider is added to the models.dev "database"? It doesn't appear that that location uses the models array in any way, but I'm not positive on way or another.

elementalvoid

What this means in a usability sense is that you cannot use /connect inside of the TUI to configure the provider. You can use env vars or you can use opencode auth login though. Once env vars are set, models are accessible.

elementalvoid

I noticed a PR today that fixes just the Gemini issues... https://github.com/anomalyco/opencode/pull/12292

Might wait for / integrate that? ¯\_(ツ)_/¯

elementalvoid

Gemini models are working for me now.

elementalvoid

This too is resolved!

fjakobs

much of this code can be replaced by the JS SDK https://www.npmjs.com/package/@databricks/sdk-experimental

As a bonus you'll also get consistency on how the other Databricks tools handle auth

dgokeeffe

Done! Refactored to use @databricks/sdk-experimental for all auth. The SDK's Config class handles credential resolution (PAT, OAuth, Azure AD, CLI profiles, etc.) and token refresh, and WorkspaceClient.servingEndpoints.list() handles model discovery. Manual CLI token cache reading and custom auth logic have been removed.

See commits 0c1ad14d8 (refactor) and 2e5dbe1c4 (bug fixes).

Changed Files

bun.lock

+417329

package.json

+10
@@ -70,6 +70,7 @@
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"baseline-browser-mapping": "2.10.0",
"glob": "13.0.5",
"husky": "9.1.7",
"prettier": "3.6.2",

packages/opencode/package.json

+10
@@ -76,6 +76,7 @@
"@ai-sdk/xai": "2.0.51",
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@databricks/sdk-experimental": "0.16.0",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5",

packages/opencode/src/auth/index.ts

+91
@@ -21,9 +21,17 @@ export namespace Auth {
.object({
type: z.literal("api"),
key: z.string(),
host: z.string().optional(), // For providers like Databricks that need a host URL
})
.meta({ ref: "ApiAuth" })
export const DatabricksProfile = z
.object({
type: z.literal("databricks-profile"),
profile: z.string(),
})
.meta({ ref: "DatabricksProfileAuth" })
export const WellKnown = z
.object({
type: z.literal("wellknown"),
@@ -32,7 +40,7 @@ export namespace Auth {
})
.meta({ ref: "WellKnownAuth" })
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" })
export const Info = z.discriminatedUnion("type", [Oauth, Api, DatabricksProfile, WellKnown]).meta({ ref: "Auth" })
export type Info = z.infer<typeof Info>
const filepath = path.join(Global.Path.data, "auth.json")

packages/opencode/src/cli/cmd/auth.ts

+360
@@ -313,6 +313,7 @@ export const AuthLoginCommand = cmd({
google: 4,
openrouter: 5,
vercel: 6,
databricks: 7,
}
const pluginProviders = resolvePluginProviders({
hooks: await Plugin.list(),
@@ -393,6 +394,24 @@ export const AuthLoginCommand = cmd({
)
}
if (provider === "databricks") {
prompts.log.info(
"Databricks Foundation Model APIs authentication:\n" +
" /connect prompts for a Databricks profile first and stores it in auth.json.\n" +
" This command stores workspace URL + Personal Access Token for fallback/manual auth.\n\n" +
"Authentication options (in priority order):\n" +
" 1. Stored /connect credentials (workspace URL + PAT)\n" +
" 2. Stored Databricks profile selection in auth.json\n" +
" 3. Environment variables: DATABRICKS_HOST + DATABRICKS_TOKEN or DATABRICKS_CONFIG_PROFILE\n" +
" 4. Databricks CLI profile: databricks auth login --profile <profile>\n" +
" 5. OAuth M2M: DATABRICKS_CLIENT_ID + DATABRICKS_CLIENT_SECRET\n" +

packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx

+162
@@ -33,6 +33,20 @@ export function DialogModel(props: { providerID?: string }) {
const favorites = connected() ? local.model.favorite() : []
const recents = local.model.recent()
const providerLabel = (provider: (typeof sync.data.provider)[number]) => {
let name = provider.name
const meta = provider.metadata as { host?: string; profile?: string } | undefined
if (meta?.profile) name += ` (${meta.profile})`
else if (meta?.host) {
try {
name += ` (${new URL(meta.host).hostname})`
} catch {
name += ` (${meta.host})`
}
}
return name
}
function toOptions(items: typeof favorites, category: string) {
if (!showSections) return []
return items.flatMap((item) => {
@@ -45,7 +59,7 @@ export function DialogModel(props: { providerID?: string }) {
key: item,
value: { providerID: provider.id, modelID: model.id },
title: model.name ?? item.modelID,
description: provider.name,
description: providerLabel(provider),
category,
disabled: provider.id === "opencode" && model.id.includes

packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx

+3671
@@ -1,4 +1,4 @@
import { createMemo, createSignal, onMount, Show } from "solid-js"
import { createMemo, createSignal, onMount, onCleanup, Show, createEffect } from "solid-js"
import { useSync } from "@tui/context/sync"
import { map, pipe, sortBy } from "remeda"
import { DialogSelect } from "@tui/ui/dialog-select"
@@ -13,6 +13,9 @@ import { DialogModel } from "./dialog-model"
import { useKeyboard } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "../ui/toast"
import { parseDatabricksProfiles, pickDatabricksProfileFlow } from "@/provider/databricks-profile"
import os from "os"
import path from "path"
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
@@ -85,6 +88,10 @@ export function createDialogProviderOptions() {
}
}
if (method.type === "api") {
// Databricks requires both host and API key
if (provider.id === "databricks") {
return dialog.replace(() => <DatabricksApiMethod providerID={provider.id} title={method.label} />)
}
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.lab

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

+104
@@ -1099,11 +1099,17 @@ export function Prompt(props: PromptProps) {
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const rawMessage = r.message.toLowerCase()
const baseMessage =
rawMessage.includes("too many requests") || rawMessage.includes("rate limit")
? "Rate limited by provider"
: message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
const duration = formatDuration(Math.max(0, seconds()))
const retryInfo = duration
? `Retrying in ${duration} (attempt ${r.attempt})`
: `Retrying now (attempt ${r.attempt})`
return `${bas

packages/opencode/src/cli/cmd/tui/context/local.tsx

+121
@@ -223,8 +223,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const provider = sync.data.provider.find((x) => x.id === value.providerID)
const info = provider?.models[value.modelID]
let name = provider?.name ?? value.providerID
const meta = provider?.metadata as { host?: string; profile?: string } | undefined
if (meta?.profile) {
name += ` (${meta.profile})`
} else if (meta?.host) {
try {
name += ` (${new URL(meta.host).hostname})`
} catch {
name += ` (${meta.host})`
}
}
return {
provider: provider?.name ?? value.providerID,
provider: name,
model: info?.name ?? value.modelID,
reasoning: info?.capabilities?.reasoning ?? false,
}

packages/opencode/src/cli/cmd/tui/context/sync.tsx

+30
@@ -36,6 +36,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
status: "loading" | "partial" | "complete"
provider: Provider[]
provider_default: Record<string, string>
provider_failed: Record<string, { host?: string; profile?: string; error?: string }>
provider_next: ProviderListResponse
provider_auth: Record<string, ProviderAuthMethod[]>
agent: Agent[]
@@ -88,6 +89,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
command: [],
provider: [],
provider_default: {},
provider_failed: {},
session: [],
session_status: {},
session_diff: {},
@@ -390,6 +392,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
batch(() => {
setStore("provider", reconcile(providers.providers))
setStore("provider_default", reconcile(providers.default))
setStore("provider_failed", reconcile((providers as any).failed ?? {}))
setStore("provider_next", reconcile(providerList))
setStore("agent", reconcile(agents))
setStore(

packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx

+290
@@ -20,6 +20,28 @@ export function Footer() {
const directory = useDirectory()
const connected = useConnected()
const databricks = createMemo(() => {
const hostname = (host: string) => {
try {
return new URL(host).hostname
} catch {
return host
}
}
const provider = sync.data.provider.find((x) => x.id === "databricks")
if (provider) {
const meta = (provider as any).metadata as { host?: string; profile?: string } | undefined
const label = meta?.profile ?? (meta?.host ? hostname(meta.host) : undefined) ?? "connected"
return { status: "connected" as const, label }
}
const failed = sync.data.provider_failed["databricks"]
if (failed) {
const label = failed.profile ?? (failed.host ? hostname(failed.host) : undefined) ?? "auth failed"
return { status: "failed" as const, label, error: failed.error }
}
return undefined
})
const [store, setStore] = createStore({
welcome: false,
})
@@ -82,6 +104,13 @@ export function Footer() {
{mcp()} MCP
</text>
</Show>
<Show when={databricks()}>

packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx

+100
@@ -20,8 +20,12 @@ export function DialogPrompt(props: DialogPromptProps) {
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
props.onConfirm?.(textarea.plainText)
}
if (evt.name === "escape") {
props.onCancel?.()
}
})
onMount(() => {
@@ -49,6 +53,12 @@ export function DialogPrompt(props: DialogPromptProps) {
onSubmit={() => {
props.onConfirm?.(textarea.plainText)
}}
onKeyDown={(e) => {
if (e.name === "return") {
e.preventDefault()
props.onConfirm?.(textarea.plainText)
}
}}
height={3}
keyBindings={[{ name: "return", action: "submit" }]}
ref={(val: TextareaRenderable) => (textarea = val)}

packages/opencode/src/provider/auth.ts

+20
@@ -121,11 +121,13 @@ export namespace ProviderAuth {
z.object({
providerID: z.string(),
key: z.string(),
host: z.string().optional(),
}),
async (input) => {
await Auth.set(input.providerID, {
type: "api",
key: input.key,
host: input.host,
})
},
)

packages/opencode/src/provider/databricks-profile.ts

+230
@@ -0,0 +1,23 @@
export function parseDatabricksProfiles(input: string) {
const profiles = input
.split("\n")
.map((line) => line.trim())
.flatMap((line) => {
const match = line.match(/^\[([^\]]+)\]$/)
if (!match) return []
const name = match[1]?.trim()
if (!name) return []
return [name]
})
return [...new Set(profiles)].toSorted((a, b) => {
if (a === "DEFAULT" && b !== "DEFAULT") return -1
if (b === "DEFAULT" && a !== "DEFAULT") return 1
return a.localeCompare(b)
})
}
export function pickDatabricksProfileFlow(input: { profiles: string[] }) {
if (input.profiles.length === 0) return {}
return { promptProfiles: input.profiles }
}

packages/opencode/src/provider/provider.ts

+4630
@@ -114,6 +114,7 @@ export namespace Provider {
autoload: boolean
getModel?: CustomModelLoader
options?: Record<string, any>
metadata?: Info["metadata"]
}>
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
@@ -588,6 +589,438 @@ export namespace Provider {
},
}
},
databricks: async (input) => {
const {
Config: DatabricksConfig,
isAnyAuthConfigured,
WorkspaceClient,
} = await import("@databricks/sdk-experimental")
const opencodeConfig = await Config.get()
const providerConfig = opencodeConfig.provider?.["databricks"]
const auth = await Auth.get("databricks")
const authProfile = auth?.type === "databricks-profile" ? auth.profile : undefined
const profile = authProfile ?? providerConfig?.options?.profile
// Build Databricks SDK Config with all available credential sources.
// The SDK handles the full auth chain: PAT, OAuth M2M, Databricks CLI,
// Azure CLI/MSI/AD, GCP credentials, and ~/.databrickscfg profiles.
const dbConfig = new DatabricksConfig({
// Use opencode's per-instance env so the SDK reads DATABRICKS_

packages/opencode/src/provider/transform.ts

+1015
@@ -49,9 +49,9 @@ export namespace ProviderTransform {
model: Provider.Model,
options: Record<string, unknown>,
): ModelMessage[] {
// Anthropic rejects messages with empty content - filter out empty string messages
// Anthropic and Databricks reject messages with empty content - filter out empty string messages
// and remove empty text/reasoning parts from array content
if (model.api.npm === "@ai-sdk/anthropic") {
if (model.api.npm === "@ai-sdk/anthropic" || model.providerID === "databricks") {
msgs = msgs
.map((msg) => {
if (typeof msg.content === "string") {
@@ -61,7 +61,7 @@ export namespace ProviderTransform {
if (!Array.isArray(msg.content)) return msg
const filtered = msg.content.filter((part) => {
if (part.type === "text" || part.type === "reasoning") {
return part.text !== ""
return (part as any).text !== ""
}
return true
})
@@ -258,7 +258,9 @@ export namespace ProviderTransform {
model.api.id.includes("claude") ||
model.id.includes("anthropic") ||
model.id.includes("claude") ||

packages/opencode/src/server/routes/config.ts

+61
@@ -73,6 +73,7 @@ export const ConfigRoutes = lazy(() =>
z.object({
providers: Provider.Info.array(),
default: z.record(z.string(), z.string()),
failed: z.record(z.string(), Provider.Info.shape.metadata.unwrap()).optional(),
}),
),
},
@@ -82,10 +83,14 @@ export const ConfigRoutes = lazy(() =>
}),
async (c) => {
using _ = log.time("providers")
const providers = await Provider.list().then((x) => mapValues(x, (item) => item))
const [providers, failed] = await Promise.all([
Provider.list().then((x) => mapValues(x, (item) => item)),
Provider.failed(),
])
return c.json({
providers: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
failed: Object.keys(failed).length > 0 ? failed : undefined,
})
},
),

packages/opencode/src/server/routes/provider.ts

+191
@@ -40,6 +40,17 @@ export const ProviderRoutes = lazy(() =>
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const allProviders = await ModelsDev.get()
// Add Databricks if not already present (it's not in models.dev)
if (!allProviders["databricks"]) {
allProviders["databricks"] = {
id: "databricks",
name: "Databricks",
env: ["DATABRICKS_TOKEN"],
models: {},
}
}
const filteredProviders: Record<string, (typeof allProviders)[string]> = {}
for (const [key, value] of Object.entries(allProviders)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
@@ -54,7 +65,14 @@ export const ProviderRoutes = lazy(() =>
)
return c.json({
all: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
default: Object.fromEntries(
Object.entries(providers)
.map(([key, item]) => {
const sorted = Provider.sort(Object.values(item.models))

packages/opencode/src/session/message-v2.ts

+146
@@ -514,7 +514,8 @@ export namespace MessageV2 {
const toModelOutput = (output: unknown) => {
if (typeof output === "string") {
return { type: "text", value: output }
// Ensure non-empty text for APIs that reject empty content (Databricks, Anthropic)
return { type: "text", value: output || "[No output]" }
}
if (typeof output === "object") {
@@ -529,7 +530,8 @@ export namespace MessageV2 {
return {
type: "content",
value: [
{ type: "text", text: outputObject.text },
// Ensure non-empty text for APIs that reject empty content
{ type: "text", text: outputObject.text || "[No output]" },
...attachments.map((attachment) => ({
type: "media",
mediaType: attachment.mime,
@@ -556,7 +558,8 @@ export namespace MessageV2 {
}
result.push(userMessage)
for (const part of msg.parts) {
if (part.type === "text" && !part.ignored)
// Skip empty or ignored text parts - some APIs reject empty text content blocks
if (part.type === "text" && !part.ignored && part.text)

packages/opencode/src/session/prompt.ts

+81
@@ -915,7 +915,14 @@ export namespace SessionPrompt {
content: result.content, // directly return content to preserve ordering when outputting to model
}
}
tools[key] = item
// Sanitize MCP tool schema the same way as built-in tools (line 701)
// MCP tools include $schema, $ref, $defs which Gemini API rejects
const rawSchema = (item.inputSchema as any)?.jsonSchema ?? {}
const sanitizedSchema = ProviderTransform.schema(input.model, rawSchema)
tools[key] = {
...item,
inputSchema: jsonSchema(sanitizedSchema as any),
}
}
return tools

packages/opencode/test/preload.ts

+70
@@ -69,6 +69,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")

packages/opencode/test/provider/databricks-profile.test.ts

+600
@@ -0,0 +1,60 @@
import { expect, test } from "bun:test"
import { Auth } from "../../src/auth"
import { parseDatabricksProfiles, pickDatabricksProfileFlow } from "../../src/provider/databricks-profile"
test("Databricks profile parsing: extracts section names", () => {
const parsed = parseDatabricksProfiles(`
[staging]
host = https://staging.cloud.databricks.com
[DEFAULT]
host = https://prod.cloud.databricks.com
[dev]
host = https://dev.cloud.databricks.com
`)
expect(parsed).toEqual(["DEFAULT", "dev", "staging"])
})
test("Databricks profile parsing: ignores non-section lines and deduplicates", () => {
const parsed = parseDatabricksProfiles(`
host = https://missing-section.cloud.databricks.com
[team-a]
token = dapi***
[team-a]
[team-b]
`)
expect(parsed).toEqual(["team-a", "team-b"])
})
test("Databricks profile flow: prompts when only one candidate exists", () => {
const flow = pickDatabricksProfileFlow({
profiles: ["DEFAULT"],
})
expect(flow).toEqual({ promptProfiles: ["DEFAULT"] })
})
test("Databricks profile flow: prompt when multiple profiles are available", () => {
const flow = pickDatabricksProfileFlow({

packages/opencode/test/provider/databricks.test.ts

+15210
@@ -0,0 +1,1521 @@
import { test, expect, mock, beforeEach } 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 = async () => ({})
Object.defineProperty(mockPlugin, "name", { value: "mockPlugin" })
mock.module("opencode-copilot-auth", () => ({ default: mockPlugin }))
mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin }))
mock.module("@gitlab/opencode-gitlab-auth", () => ({

packages/opencode/test/provider/transform.test.ts

+14780
@@ -847,6 +847,442 @@ describe("ProviderTransform.message - empty image handling", () => {
})
})
describe("ProviderTransform.message - databricks empty content filtering", () => {
// Test with Databricks Claude (Anthropic model via OpenAI-compatible API)
const databricksClaudeModel = {
id: "databricks-claude-sonnet-4",
providerID: "databricks",
api: {
id: "databricks-claude-sonnet-4",
url: "https://my-workspace.cloud.databricks.com/serving-endpoints",
npm: "@ai-sdk/openai-compatible",
},
name: "Claude Sonnet 4 (Databricks)",
capabilities: {
temperature: true,
reasoning: false,
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: 3,
output: 15,
cache: { read: 0.3, write: 0 },
},
limit: {
context: 200000,
output: 64000,
},
status: "active",
options: {},
headers: {},
} as any
// Test with Databricks GPT-5 (OpenAI model via

packages/sdk/js/src/v2/gen/types.gen.ts

+191
@@ -1518,6 +1518,12 @@ export type OAuth = {
export type ApiAuth = {
type: "api"
key: string
host?: string
}
export type DatabricksProfileAuth = {
type: "databricks-profile"
profile: string
}
export type WellKnownAuth = {
@@ -1526,7 +1532,7 @@ export type WellKnownAuth = {
token: string
}
export type Auth = OAuth | ApiAuth | WellKnownAuth
export type Auth = OAuth | ApiAuth | DatabricksProfileAuth | WellKnownAuth
export type NotFoundError = {
name: "NotFoundError"
@@ -1618,6 +1624,11 @@ export type Provider = {
models: {
[key: string]: Model
}
metadata?: {
host?: string
profile?: string
error?: string
}
}
export type ToolIds = Array<string>
@@ -2380,6 +2391,13 @@ export type ConfigProvidersResponses = {
default: {
[key: string]: string
}
failed?: {
[key: string]: {
host?: string
profile?: string
error?: string
}
}
}
}

packages/ui/package.json

+20
@@ -32,8 +32,10 @@
"@tailwindcss/vite": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/dompurify": "3.2.0",
"@types/katex": "0.16.7",
"@types/luxon": "catalog:",
"@types/strip-ansi": "5.2.1",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",

packages/web/src/content/docs/providers.mdx

+1490
@@ -1816,6 +1816,155 @@ Some useful routing options:
---
### Databricks
To use Databricks Foundation Model APIs with OpenCode:
#### Quick Start with Databricks CLI (Recommended)
If you have the [Databricks CLI](https://docs.databricks.com/en/dev-tools/cli/index.html) installed and authenticated, OpenCode will automatically detect and use your credentials:
1. Authenticate with the Databricks CLI:
```bash
databricks auth login --host https://your-workspace.cloud.databricks.com
```
2. Run the `/connect` command and search for **Databricks**.
```txt
/connect
```
:::tip
If you have valid CLI credentials, OpenCode will automatically detect them and skip the manual authentication prompts.
:::
3. Run the `/models` command to see available Databricks models.
```txt
/models
```
#### Manual Setup
If you don't have the Databricks CLI, you can authenticate manually:
1. Run the `/connect` command and search for **Databricks**.
```txt
/connect
```
2. If SDK auth is not already available, enter your Databricks workspace URL and Personal Access Token.
```txt
┌ Databricks Host URL