diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index dbbc8fa7adc..9dd6efd6881 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -21,34 +21,32 @@ export const DialogSettings: Component = () => { -
-
- {language.t("settings.section.desktop")} -
- - - {language.t("settings.tab.general")} - - - - {language.t("settings.tab.shortcuts")} - +
+
+
+
+ {language.t("settings.section.desktop")} +
+ + + {language.t("settings.tab.general")} + + + + {language.t("settings.tab.shortcuts")} + +
+
+ +
+ {language.t("settings.section.server")} +
+ + + {language.t("settings.providers.title")} + +
+
@@ -56,31 +54,6 @@ export const DialogSettings: Component = () => { v{platform.version}
- {/* Server */} - {/* */} - {/* */} - {/* Permissions */} - {/* */} - {/* */} - {/* */} - {/* Providers */} - {/* */} - {/* */} - {/* */} - {/* Models */} - {/* */} - {/* */} - {/* */} - {/* Agents */} - {/* */} - {/* */} - {/* */} - {/* Commands */} - {/* */} - {/* */} - {/* */} - {/* MCP */} - {/* */} @@ -88,12 +61,9 @@ export const DialogSettings: Component = () => { - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} + + + {/* */} {/* */} {/* */} diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 7b6ca193924..aec6d6c4f06 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -1,14 +1,154 @@ -import { Component } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { Tag } from "@opencode-ai/ui/tag" +import { showToast } from "@opencode-ai/ui/toast" +import type { IconName } from "@opencode-ai/ui/icons/provider" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { createMemo, type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" +import { useGlobalSDK } from "@/context/global-sdk" +import { DialogConnectProvider } from "./dialog-connect-provider" +import { DialogSelectProvider } from "./dialog-select-provider" + +type ProviderSource = "env" | "api" | "config" | "custom" +type ProviderMeta = { source?: ProviderSource } export const SettingsProviders: Component = () => { + const dialog = useDialog() const language = useLanguage() + const globalSDK = useGlobalSDK() + const providers = useProviders() + + const connected = createMemo(() => providers.connected()) + const popular = createMemo(() => { + const connectedIDs = new Set(connected().map((p) => p.id)) + const items = providers + .popular() + .filter((p) => !connectedIDs.has(p.id)) + .slice() + items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)) + return items + }) + + const source = (item: unknown) => (item as ProviderMeta).source + + const type = (item: unknown) => { + const current = source(item) + if (current === "env") return language.t("settings.providers.tag.environment") + if (current === "api") return language.t("provider.connect.method.apiKey") + if (current === "config") return language.t("settings.providers.tag.config") + if (current === "custom") return language.t("settings.providers.tag.custom") + return language.t("settings.providers.tag.other") + } + + const canDisconnect = (item: unknown) => source(item) !== "env" + + const disconnect = async (providerID: string, name: string) => { + await globalSDK.client.auth + .remove({ providerID }) + .then(async () => { + await globalSDK.client.global.dispose() + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }), + description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }), + }) + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + } return ( -
-
-

{language.t("settings.providers.title")}

-

{language.t("settings.providers.description")}

+
+
+
+

{language.t("settings.providers.title")}

+
+
+ +
+
+

{language.t("settings.providers.section.connected")}

+
+ 0} + fallback={ +
+ {language.t("settings.providers.connected.empty")} +
+ } + > + + {(item) => ( +
+
+ + {item.name} + {type(item)} +
+ + + +
+ )} +
+
+
+
+ +
+

{language.t("settings.providers.section.popular")}

+
+ + {(item) => ( +
+
+ + {item.name} + + {language.t("dialog.provider.tag.recommended")} + + +
{language.t("dialog.provider.anthropic.note")}
+
+ +
{language.t("dialog.provider.openai.note")}
+
+ +
{language.t("dialog.provider.copilot.note")}
+
+
+ +
+ )} +
+
+ + +
) diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 99e516a0a49..9fcd8ba6dbf 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -100,7 +100,7 @@ export const dict = { "dialog.model.unpaid.freeModels.title": "نماذج مجانية مقدمة من OpenCode", "dialog.model.unpaid.addMore.title": "إضافة المزيد من النماذج من موفرين مشهورين", - "dialog.provider.viewAll": "عرض جميع الموفرين", + "dialog.provider.viewAll": "عرض المزيد من الموفرين", "provider.connect.title": "اتصال {{provider}}", "provider.connect.title.anthropicProMax": "تسجيل الدخول باستخدام Claude Pro/Max", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 93dc2f1febe..a0c904dfbc2 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -100,7 +100,7 @@ export const dict = { "dialog.model.unpaid.freeModels.title": "Modelos gratuitos fornecidos pelo OpenCode", "dialog.model.unpaid.addMore.title": "Adicionar mais modelos de provedores populares", - "dialog.provider.viewAll": "Ver todos os provedores", + "dialog.provider.viewAll": "Ver mais provedores", "provider.connect.title": "Conectar {{provider}}", "provider.connect.title.anthropicProMax": "Entrar com Claude Pro/Max", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index b2f0a9afe74..4dc4a2cfb2c 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -98,7 +98,7 @@ export const dict = { "dialog.model.unpaid.freeModels.title": "Gratis modeller leveret af OpenCode", "dialog.model.unpaid.addMore.title": "Tilføj flere modeller fra populære udbydere", - "dialog.provider.viewAll": "Vis alle udbydere", + "dialog.provider.viewAll": "Vis flere udbydere", "provider.connect.title": "Forbind {{provider}}", "provider.connect.title.anthropicProMax": "Log ind med Claude Pro/Max", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 42f628d5ed6..69bf1fb4945 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -102,7 +102,7 @@ export const dict = { "dialog.model.unpaid.freeModels.title": "Kostenlose Modelle von OpenCode", "dialog.model.unpaid.addMore.title": "Weitere Modelle von beliebten Anbietern hinzufügen", - "dialog.provider.viewAll": "Alle Anbieter anzeigen", + "dialog.provider.viewAll": "Mehr Anbieter anzeigen", "provider.connect.title": "{{provider}} verbinden", "provider.connect.title.anthropicProMax": "Mit Claude Pro/Max anmelden", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index b32f0348551..770b021364e 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -100,7 +100,7 @@ export const dict = { "dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode", "dialog.model.unpaid.addMore.title": "Add more models from popular providers", - "dialog.provider.viewAll": "View all providers", + "dialog.provider.viewAll": "Show more providers", "provider.connect.title": "Connect {{provider}}", "provider.connect.title.anthropicProMax": "Login with Claude Pro/Max", @@ -137,6 +137,9 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} connected", "provider.connect.toast.connected.description": "{{provider}} models are now available to use.", + "provider.disconnect.toast.disconnected.title": "{{provider}} disconnected", + "provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.", + "model.tag.free": "Free", "model.tag.latest": "Latest", "model.provider.anthropic": "Anthropic", @@ -159,6 +162,8 @@ export const dict = { "common.loading": "Loading", "common.loading.ellipsis": "...", "common.cancel": "Cancel", + "common.connect": "Connect", + "common.disconnect": "Disconnect", "common.submit": "Submit", "common.save": "Save", "common.saving": "Saving...", @@ -491,6 +496,7 @@ export const dict = { "sidebar.project.viewAllSessions": "View all sessions", "settings.section.desktop": "Desktop", + "settings.section.server": "Server", "settings.tab.general": "General", "settings.tab.shortcuts": "Shortcuts", @@ -599,6 +605,13 @@ export const dict = { "settings.providers.title": "Providers", "settings.providers.description": "Provider settings will be configurable here.", + "settings.providers.section.connected": "Connected providers", + "settings.providers.connected.empty": "No connected providers", + "settings.providers.section.popular": "Popular providers", + "settings.providers.tag.environment": "Environment", + "settings.providers.tag.config": "Config", + "settings.providers.tag.custom": "Custom", + "settings.providers.tag.other": "Other", "settings.models.title": "Models", "settings.models.description": "Model settings will be configurable here.", "settings.agents.title": "Agents", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 1039a2d3a4c..c715bc048bd 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -98,7 +98,7 @@ export const dict = { "dialog.model.unpaid.freeModels.title": "Modelos gratuitos proporcionados por OpenCode", "dialog.model.unpaid.addMore.title": "Añadir más modelos de proveedores populares", - "dialog.provider.viewAll": "Ver todos los proveedores", + "dialog.provider.viewAll": "Ver más proveedores", "provider.connect.title": "Conectar {{provider}}", "provider.connect.title.anthropicProMax": "Iniciar sesión con Claude Pro/Max", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 09eeea44c46..8bd8dba25b8 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -98,7 +98,7 @@ export const dict = { "dialog.model.unpaid.freeModels.title": "Modèles gratuits fournis par OpenCode", "dialog.model.unpaid.addMore.title": "Ajouter plus de modèles de fournisseurs populaires", - "dialog.provider.viewAll": "Voir tous les fournisseurs", + "dialog.provider.viewAll": "Voir plus de fournisseurs", "provider.connect.title": "Connecter {{provider}}", "provider.connect.title.anthropicProMax": "Connexion avec Claude Pro/Max", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 821c6ccdb12..5b98f5aa92c 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -98,7 +98,7 @@ export const dict = { "dialog.model.unpaid.freeModels.title": "OpenCodeが提供する無料モデル", "dialog.model.unpaid.addMore.title": "人気のプロバイダーからモデルを追加", - "dialog.provider.viewAll": "すべてのプロバイダーを表示", + "dialog.provider.viewAll": "さらにプロバイダーを表示", "provider.connect.title": "{{provider}}を接続", "provider.connect.title.anthropicProMax": "Claude Pro/Maxでログイン", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index ddd00e763d8..a016cd34a45 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -102,7 +102,7 @@ export const dict = { "dialog.model.unpaid.freeModels.title": "OpenCode에서 제공하는 무료 모델", "dialog.model.unpaid.addMore.title": "인기 공급자의 모델 추가", - "dialog.provider.viewAll": "모든 공급자 보기", + "dialog.provider.viewAll": "더 많은 공급자 보기", "provider.connect.title": "{{provider}} 연결", "provider.connect.title.anthropicProMax": "Claude Pro/Max로 로그인", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 3262d3e04c4..153ee04122e 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -103,7 +103,7 @@ export const dict = { "dialog.model.unpaid.freeModels.title": "Gratis modeller levert av OpenCode", "dialog.model.unpaid.addMore.title": "Legg til flere modeller fra populære leverandører", - "dialog.provider.viewAll": "Vis alle leverandører", + "dialog.provider.viewAll": "Vis flere leverandører", "provider.connect.title": "Koble til {{provider}}", "provider.connect.title.anthropicProMax": "Logg inn med Claude Pro/Max", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 7af9d217985..db102628471 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -100,7 +100,7 @@ export const dict = { "dialog.model.unpaid.freeModels.title": "Darmowe modele dostarczane przez OpenCode", "dialog.model.unpaid.addMore.title": "Dodaj więcej modeli od popularnych dostawców", - "dialog.provider.viewAll": "Zobacz wszystkich dostawców", + "dialog.provider.viewAll": "Zobacz więcej dostawców", "provider.connect.title": "Połącz {{provider}}", "provider.connect.title.anthropicProMax": "Zaloguj się z Claude Pro/Max", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index d7fa135fa0f..d8b94cb107d 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -100,7 +100,7 @@ export const dict = { "dialog.model.unpaid.freeModels.title": "Бесплатные модели от OpenCode", "dialog.model.unpaid.addMore.title": "Добавьте больше моделей от популярных провайдеров", - "dialog.provider.viewAll": "Посмотреть всех провайдеров", + "dialog.provider.viewAll": "Показать больше провайдеров", "provider.connect.title": "Подключить {{provider}}", "provider.connect.title.anthropicProMax": "Войти с помощью Claude Pro/Max", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index e2b7df0d10e..cfecb739d88 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -102,7 +102,7 @@ export const dict = { "dialog.model.unpaid.freeModels.title": "OpenCode 提供的免费模型", "dialog.model.unpaid.addMore.title": "从热门提供商添加更多模型", - "dialog.provider.viewAll": "查看全部提供商", + "dialog.provider.viewAll": "查看更多提供商", "provider.connect.title": "连接 {{provider}}", "provider.connect.title.anthropicProMax": "使用 Claude Pro/Max 登录", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 9973b443b34..050c160cdfb 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -102,7 +102,7 @@ export const dict = { "dialog.model.unpaid.freeModels.title": "OpenCode 提供的免費模型", "dialog.model.unpaid.addMore.title": "從熱門提供者新增更多模型", - "dialog.provider.viewAll": "查看全部提供者", + "dialog.provider.viewAll": "查看更多提供者", "provider.connect.title": "連線 {{provider}}", "provider.connect.title.anthropicProMax": "使用 Claude Pro/Max 登入", diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index fdd4ccdfb61..f898d3be430 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -854,10 +854,9 @@ export namespace Provider { // Load for the main provider if auth exists if (auth) { const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider]) - mergeProvider(plugin.auth.provider, { - source: "custom", - options: options, - }) + const opts = options ?? {} + const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } + mergeProvider(providerID, patch) } // If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists @@ -870,10 +869,11 @@ export namespace Provider { () => Auth.get(enterpriseProviderID) as any, database[enterpriseProviderID], ) - mergeProvider(enterpriseProviderID, { - source: "custom", - options: enterpriseOptions, - }) + const opts = enterpriseOptions ?? {} + const patch: Partial = providers[enterpriseProviderID] + ? { options: opts } + : { source: "custom", options: opts } + mergeProvider(enterpriseProviderID, patch) } } } @@ -889,10 +889,9 @@ export namespace Provider { const result = await fn(data) if (result && (result.autoload || providers[providerID])) { if (result.getModel) modelLoaders[providerID] = result.getModel - mergeProvider(providerID, { - source: "custom", - options: result.options, - }) + const opts = result.options ?? {} + const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } + mergeProvider(providerID, patch) } } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index fa646f21ea8..302c5376d29 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -441,6 +441,36 @@ export namespace Server { return c.json(true) }, ) + .delete( + "/auth/:providerID", + describeRoute({ + summary: "Remove auth credentials", + description: "Remove authentication credentials", + operationId: "auth.remove", + responses: { + 200: { + description: "Successfully removed authentication credentials", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: z.string(), + }), + ), + async (c) => { + const providerID = c.req.valid("param").providerID + await Auth.remove(providerID) + return c.json(true) + }, + ) .get( "/event", describeRoute({ diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 8a2009646e0..482587d8ac5 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -46,9 +46,10 @@ test("provider loaded from env variable", async () => { fn: async () => { const providers = await Provider.list() expect(providers["anthropic"]).toBeDefined() - // Note: source becomes "custom" because CUSTOM_LOADERS run after env loading - // and anthropic has a custom loader that merges additional options - expect(providers["anthropic"].source).toBe("custom") + // Provider should retain its connection source even if custom loaders + // merge additional options. + expect(providers["anthropic"].source).toBe("env") + expect(providers["anthropic"].options.headers["anthropic-beta"]).toBeDefined() }, }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 67e7ac80cb9..d39dd2b3485 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -9,6 +9,8 @@ import type { AppLogResponses, AppSkillsResponses, Auth as Auth3, + AuthRemoveErrors, + AuthRemoveResponses, AuthSetErrors, AuthSetResponses, CommandListResponses, @@ -3054,6 +3056,36 @@ export class Formatter extends HeyApiClient { } export class Auth2 extends HeyApiClient { + /** + * Remove auth credentials + * + * Remove authentication credentials + */ + public remove( + parameters: { + providerID: string + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/auth/{providerID}", + ...options, + ...params, + }) + } + /** * Set auth credentials * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 38a52b325ad..9258bc0cde6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4867,6 +4867,35 @@ export type FormatterStatusResponses = { export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] +export type AuthRemoveData = { + body?: never + path: { + providerID: string + } + query?: { + directory?: string + } + url: "/auth/{providerID}" +} + +export type AuthRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors] + +export type AuthRemoveResponses = { + /** + * Successfully removed authentication credentials + */ + 200: boolean +} + +export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses] + export type AuthSetData = { body?: Auth path: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index a029d0ef0ed..8808bcf7d8c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5709,6 +5709,56 @@ "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.set({\n ...\n})" } ] + }, + "delete": { + "operationId": "auth.remove", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "providerID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Remove auth credentials", + "description": "Remove authentication credentials", + "responses": { + "200": { + "description": "Successfully removed authentication credentials", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.remove({\n ...\n})" + } + ] } }, "/event": {