Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Info> = {
source: "api",
key: provider.key,
})
}
if (provider.metadata?.baseURL) {
patch.options = { baseURL: provider.metadata.baseURL }
}
mergeProvider(providerID, patch)
}
}

Expand Down
29 changes: 28 additions & 1 deletion packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const paid = (providers: Record<string, { models: Record<string, { cost: { input

const languageBaseURL = (language: unknown) => (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 = {
Expand Down Expand Up @@ -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* () {
Expand Down
104 changes: 104 additions & 0 deletions packages/tui/src/component/dialog-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ type ProviderOption =
| (ProviderOptionBase & {
type: "custom"
})
| (ProviderOptionBase & {
type: "localhost"
})

export function providerOptions(list: { id: string; name: string }[]): ProviderOption[] {
return [
Expand All @@ -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",
Expand Down Expand Up @@ -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<string | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select local provider"
options={[
{ title: "Ollama", value: "ollama" },
{ title: "LM Studio", value: "lm-studio" },
{ title: "Llama.cpp", value: "llama-cpp" },
{ title: "Other (custom ID)", value: "custom" },
]}
onSelect={(option) => 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: () => (
<text fg={theme.textMuted}>
Enter the local endpoint URL (e.g. {defaultLink})
</text>
),
})
if (apiLink === null) return

const apiKey = await DialogPrompt.show(dialog, "API Key", {
placeholder: "None (optional)",
description: () => (
<text fg={theme.textMuted}>
Enter an API key if required by your local server
</text>
),
})
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(() => <DialogModel providerID={providerID} />)
},
}
}

if (provider.type === "custom") {
return {
title: provider.title,
Expand Down
Loading