diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d7c85f2ee6ae..9fdade0aee94 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1487,10 +1487,14 @@ export const layer = Layer.effect( const providerID = ProviderV2.ID.make(id) if (disabled.has(providerID)) continue if (provider.type === "api") { - mergeProvider(providerID, { + const patch: Partial = { source: "api", key: provider.key, - }) + } + if (provider.metadata?.baseURL) { + patch.options = { baseURL: provider.metadata.baseURL } + } + mergeProvider(providerID, patch) } } diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 6edfc97ca06e..0e8613dc88d2 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -77,7 +77,7 @@ const paid = (providers: Record (language as { config: { baseURL: string } }).config.baseURL -const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer)) +const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer, Auth.defaultLayer)) const experimentalModels = testEffect(providerLayer({ enableExperimentalModels: true })) const alphaProviderConfig = { @@ -526,6 +526,33 @@ it.instance( }, ) +it.instance("provider loaded from auth metadata with baseURL", () => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("custom-auth-provider", { + type: "api", + key: "test-api-key", + metadata: { baseURL: "http://localhost:1234/v1" } + }) + const providers = yield* list + expect(providers[ProviderV2.ID.make("custom-auth-provider")]).toBeDefined() + expect(providers[ProviderV2.ID.make("custom-auth-provider")].options.baseURL).toBe("http://localhost:1234/v1") + expect(providers[ProviderV2.ID.make("custom-auth-provider")].key).toBe("test-api-key") + }), + { + config: { + provider: { + "custom-auth-provider": { + name: "Custom Auth Provider", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { "model-1": { name: "Model 1", tool_call: true, limit: { context: 8000, output: 2000 } } }, + }, + }, + }, + }, +) + it.instance( "model inherits properties from existing database model", Effect.gen(function* () { diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx index 2767508eb2d2..65105c363494 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -43,6 +43,9 @@ type ProviderOption = | (ProviderOptionBase & { type: "custom" }) + | (ProviderOptionBase & { + type: "localhost" + }) export function providerOptions(list: { id: string; name: string }[]): ProviderOption[] { return [ @@ -67,6 +70,13 @@ export function providerOptions(list: { id: string; name: string }[]): ProviderO category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Providers", })), ), + { + type: "localhost", + title: "Localhost", + value: "__opencode_localhost__", + description: "Ollama, LM Studio, etc.", + category: "Providers", + }, { type: "custom", title: "Other", @@ -113,10 +123,104 @@ export function createDialogProviderOptions() { return promptCustomProviderID() } + async function promptLocalhostSetup(): Promise<{ providerID: string; apiLink: string; apiKey: string } | undefined> { + const providerID = await new Promise((resolve) => { + dialog.replace( + () => ( + resolve(option.value)} + /> + ), + () => resolve(null), + ) + }) + if (!providerID) return + + let finalProviderID = providerID + if (providerID === "custom") { + const customID = await promptCustomProviderID() + if (!customID) return + finalProviderID = customID + } + + const defaultLink = { + ollama: "http://localhost:11434/v1", + "lm-studio": "http://localhost:1234/v1", + "llama-cpp": "http://localhost:8080/v1", + }[providerID] ?? "http://localhost:11434/v1" + + const apiLink = await DialogPrompt.show(dialog, "API Link (baseURL)", { + placeholder: defaultLink, + description: () => ( + + Enter the local endpoint URL (e.g. {defaultLink}) + + ), + }) + if (apiLink === null) return + + const apiKey = await DialogPrompt.show(dialog, "API Key", { + placeholder: "None (optional)", + description: () => ( + + Enter an API key if required by your local server + + ), + }) + if (apiKey === null) return + + return { + providerID: finalProviderID, + apiLink: apiLink.trim() || defaultLink, + apiKey: apiKey.trim() || "no-key", + } + } + const options = createMemo(() => { return pipe( providerOptions(sync.data.provider_next.all), map((provider) => { + if (provider.type === "localhost") { + return { + title: provider.title, + value: provider.value, + description: provider.description, + category: provider.category, + async onSelect() { + const result = await promptLocalhostSetup() + if (!result) return + const { providerID, apiLink, apiKey } = result + + await sdk.client.auth.set({ + providerID, + auth: { + type: "api", + key: apiKey, + metadata: { baseURL: apiLink }, + }, + }) + await sdk.client.instance.dispose() + await sync.bootstrap() + if (!sync.data.provider_next.all.some((p) => p.id === providerID)) { + toast.show({ + variant: "info", + message: `Saved local credential for ${providerID}. Configure it in opencode.json to use it.`, + }) + dialog.clear() + return + } + dialog.replace(() => ) + }, + } + } + if (provider.type === "custom") { return { title: provider.title,