diff --git a/AGENTS.md b/AGENTS.md
index 7e637e67..602fcaea 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1745,7 +1745,7 @@ What `CHECKLIST.md` asked to be written down, and where the answer lives.
row on the Settings root, the signed-in count beside it, pushing
`ModelProvidersController`: signed in first with who as and a `…` menu,
then the rest under Sign In with an Account and API Keys, searchable)
- signs in to every login kwwk offers, plus DeepSeek. API-key fields use native inset
+ signs in to every login kwwk offers, plus DeepSeek and OpenCode Go. API-key fields use native inset
grouped sections, with Fetch Models in the navigation bar. Setup stores
the key and gets the endpoint's model list before model selection; no
arbitrary default model is probed. The selection page allows custom IDs
@@ -1791,6 +1791,10 @@ What `CHECKLIST.md` asked to be written down, and where the answer lives.
extras — what KWWKCli's internal `apiKeyCredentials` writes, so it is
rewritten in `AccountDesk`). DeepSeek adds an API-key form and its own
`deepseek-api-key` credential item, separate from `openai-compatible`.
+ OpenCode Go adds a key-only form (`oc_sk_…`) under `opencode-go`, the Go
+ subscription's gateway; it is not the pay-as-you-go Console, and a general
+ OpenAI-compatible entry cannot reach it (Go answers 400 `MissingSessionID`
+ without `x-opencode-session`, before it looks at the key).
Display order is Apelin's `AgentProvider.signInOrder`, independent of
kwwk's registration order;
`AccountDeskTests` fails when kwwk gains a subscription or a stored
@@ -1808,7 +1812,19 @@ What `CHECKLIST.md` asked to be written down, and where the answer lives.
other is `.stored(storeID:, model:)`. `ProviderRegistration` is shared
by account verification and conversations: it uses kwwk's registration
and transports, adds DeepSeek, and applies DeepSeek's catalog capabilities
- to its official Chat Completions endpoint. Unknown models keep their
+ to its official Chat Completions endpoint. It also adds OpenCode Go
+ (`Session/OpenCodeGoProvider.swift`): kwwk's catalog (`opencode-go`) names
+ each model's wire, and Apelin registers that model on Go's documented
+ family base — Chat Completions and Responses at `/openai/v1`, Anthropic at
+ `/anthropic`, Gemini at `/google` — wrapped to send `x-opencode-session`
+ from the conversation's session id (a process id for verification and
+ other requests with no conversation), following Copilot's header adapter
+ and forwarding session cleanup and native compaction. The family base is
+ not the catalog's `baseUrl`: kwwk's transports append their own version
+ and path, and the pinned Google entry would append `/v1beta` twice.
+ Unknown ids fall back to Chat Completions, which Console translates, with
+ a conservative text-only, non-reasoning model; a kwwk that registers
+ `opencode-go` itself can replace this adapter. Unknown models keep their
existing capabilities; model names alone never identify a provider. The
app cannot write the keychain group, so every flow runs in apelin-agent's
`credential-import` instance (`AccountDesk`), which stores the result
diff --git a/Apelin/Interface/Settings/Accounts/AgentProvider+Display.swift b/Apelin/Interface/Settings/Accounts/AgentProvider+Display.swift
index 169be904..d05175d2 100644
--- a/Apelin/Interface/Settings/Accounts/AgentProvider+Display.swift
+++ b/Apelin/Interface/Settings/Accounts/AgentProvider+Display.swift
@@ -4,7 +4,8 @@ import UIKit
/// How Settings shows each provider: its name, its mark and the one line
/// that says what signing in to it means. Marks live in `Provider*.imageset`;
/// DeepSeek's is the unmodified official whale SVG:
-/// https://api-docs.deepseek.com/img/favicon.svg
+/// https://api-docs.deepseek.com/img/favicon.svg and OpenCode Go's is the
+/// unmodified official favicon SVG: https://opencode.ai/favicon.svg
/// Which providers there are, and in
/// what order, is apelin-agent's answer, not this file's.
extension AgentProvider {
@@ -30,6 +31,7 @@ extension AgentProvider {
case .openAI: String(localized: "OpenAI API")
case .google: String(localized: "Google AI Studio")
case .deepSeek: String(localized: "DeepSeek")
+ case .openCodeGo: String(localized: "OpenCode Go")
}
}
@@ -55,6 +57,7 @@ extension AgentProvider {
case .openAI: UIImage(named: "ProviderOpenAIAPI")
case .google: UIImage(named: "ProviderGoogleAI")
case .deepSeek: UIImage(named: "ProviderDeepSeek")
+ case .openCodeGo: UIImage(named: "ProviderOpenCodeGo")
}
}
@@ -101,6 +104,8 @@ extension AgentProvider {
String(localized: "Use a Gemini API key from Google AI Studio.")
case .deepSeek:
String(localized: "Use an API key from the DeepSeek platform.")
+ case .openCodeGo:
+ String(localized: "Use an API key from the OpenCode Console with a Go subscription.")
}
}
}
diff --git a/Apelin/Resources/Assets.xcassets/ProviderOpenCodeGo.imageset/Contents.json b/Apelin/Resources/Assets.xcassets/ProviderOpenCodeGo.imageset/Contents.json
new file mode 100644
index 00000000..e751f482
--- /dev/null
+++ b/Apelin/Resources/Assets.xcassets/ProviderOpenCodeGo.imageset/Contents.json
@@ -0,0 +1,7 @@
+{
+ "images" : [
+ { "filename" : "opencode-go.svg", "idiom" : "universal" }
+ ],
+ "info" : { "author" : "xcode", "version" : 1 },
+ "properties" : { "preserves-vector-representation" : true }
+}
diff --git a/Apelin/Resources/Assets.xcassets/ProviderOpenCodeGo.imageset/opencode-go.svg b/Apelin/Resources/Assets.xcassets/ProviderOpenCodeGo.imageset/opencode-go.svg
new file mode 100644
index 00000000..157edc4d
--- /dev/null
+++ b/Apelin/Resources/Assets.xcassets/ProviderOpenCodeGo.imageset/opencode-go.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/Apelin/Resources/Localizable.xcstrings b/Apelin/Resources/Localizable.xcstrings
index 08874a72..923e1672 100644
--- a/Apelin/Resources/Localizable.xcstrings
+++ b/Apelin/Resources/Localizable.xcstrings
@@ -1,6 +1,93 @@
{
"sourceLanguage" : "en",
"strings" : {
+ "OpenCode Go" : {
+ "extractionState" : "manual",
+ "shouldTranslate" : false
+ },
+ "Use an API key from the OpenCode Console with a Go subscription." : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "استخدم مفتاح API من وحدة تحكم OpenCode مع اشتراك Go."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verwende einen API-Schlüssel aus der OpenCode Console mit einem Go-Abo."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Use an API key from the OpenCode Console with a Go subscription."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usa una clave de API de la consola de OpenCode con una suscripción Go."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utilisez une clé API de la console OpenCode avec un abonnement Go."
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usa una chiave API dalla console di OpenCode con un abbonamento Go."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "OpenCode コンソールの API キーを Go サブスクリプションで使用します。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Go 구독으로 OpenCode 콘솔의 API 키를 사용하세요."
+ }
+ },
+ "pt-BR" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Use uma chave de API do console do OpenCode com uma assinatura Go."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Используйте ключ API из консоли OpenCode с подпиской Go."
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sử dụng khóa API từ bảng điều khiển OpenCode với gói đăng ký Go."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用 OpenCode 控制台中 Go 订阅的 API 密钥。"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用 OpenCode 控制台中 Go 訂閱的 API 金鑰。"
+ }
+ }
+ }
+ },
"%lld Messages Not Sent" : {
"extractionState" : "manual",
"localizations" : {
diff --git a/Packages/ApelinAgent/Sources/ApelinAgent/Credentials/CredentialSource.swift b/Packages/ApelinAgent/Sources/ApelinAgent/Credentials/CredentialSource.swift
index 2aca82f0..d200ad3c 100644
--- a/Packages/ApelinAgent/Sources/ApelinAgent/Credentials/CredentialSource.swift
+++ b/Packages/ApelinAgent/Sources/ApelinAgent/Credentials/CredentialSource.swift
@@ -21,13 +21,15 @@ public protocol CredentialSource: OAuthCredentialSource {
func remove(_ providerID: String) async throws
}
-/// Credential store ids, including Apelin's independent DeepSeek account.
+/// Credential store ids, including Apelin's independent DeepSeek and
+/// OpenCode Go accounts.
public enum CredentialProviderID {
public static let anthropicAPIKey = "anthropic-api-key"
public static let anthropicOAuth = "anthropic"
public static let openAICompatible = "openai-compatible"
public static let githubCopilot = "github-copilot"
public static let deepSeek = "deepseek-api-key"
+ public static let openCodeGo = "opencode-go"
}
/// Credentials delivered by the app for this session, held in memory only.
diff --git a/Packages/ApelinAgent/Sources/ApelinAgent/Session/OpenCodeGoProvider.swift b/Packages/ApelinAgent/Sources/ApelinAgent/Session/OpenCodeGoProvider.swift
new file mode 100644
index 00000000..e77cc3d5
--- /dev/null
+++ b/Packages/ApelinAgent/Sources/ApelinAgent/Session/OpenCodeGoProvider.swift
@@ -0,0 +1,178 @@
+import Foundation
+import KWWKAI
+
+/// OpenCode Go, opencode.ai's coding subscription, by the API key the
+/// OpenCode Console issues. kwwk 0.1.100 carries the catalog
+/// (`opencode-go`) but no login for it, so this registers each model on the
+/// wire its catalog entry names and stamps the header Go routes by.
+///
+/// Go refuses a request without `x-opencode-session` with HTTP 400
+/// `MissingSessionID`, before it looks at the key:
+/// https://opencode.ai/v2/docs/console/go#where-can-i-use-it
+/// kwwk sends every other session header but not that one, which is why an
+/// OpenAI-compatible entry cannot reach Go.
+///
+/// The routes are Go's documented family bases, not the catalog's
+/// `baseUrl`: kwwk's transports append their own version and path, and the
+/// pinned catalog's Google base would append `/v1beta` twice. See
+/// https://opencode.ai/v2/docs/console/harnesses#base-urls.
+public enum OpenCodeGoProvider {
+ public static let storeID = CredentialProviderID.openCodeGo
+ public static let catalogKey = "opencode-go"
+
+ /// The gateway's host for the Go plan. The model list lives at
+ /// `modelListBaseURL`, not under the OpenAI family base.
+ public static let host = "https://opencode.ai/inference/go"
+ public static let modelListBaseURL = "\(host)/v1"
+
+ /// Go has no default of its own. This one is cheap, text-only and
+ /// listed on the Go plan.
+ public static let defaultModel = "glm-5.2"
+
+ /// The header Go routes and caches a conversation by.
+ static let sessionHeader = "x-opencode-session"
+
+ /// One id for requests that have no conversation of their own: a model
+ /// verification, a title completion. Go wants one on every request.
+ static let processSession = UUID().uuidString.lowercased()
+
+ /// Registers the model on its wire and returns what the session uses.
+ /// `modelOverride` is the model the app chose; a stored `defaultModel`
+ /// (an imported credential) is the fallback before `glm-5.2`.
+ static func resolve(credentials: OAuthCredentials, modelOverride: String?) async -> ResolvedAuth {
+ let id = modelOverride ?? extra("defaultModel", in: credentials) ?? defaultModel
+ let catalog = ModelsCatalog.model(provider: catalogKey, id: id)
+ let api = supported(catalog?.api)
+
+ var model = catalog ?? fallbackModel(id: id, api: api)
+ model.api = api
+ model.provider = storeID
+ model.baseURL = extra("baseUrl", in: credentials) ?? baseURL(for: api)
+
+ await register(api: api)
+ return ResolvedAuth(
+ model: model,
+ modelLabel: "\(model.name) · OpenCode Go",
+ authResolver: { _, _ in
+ ResolvedProviderAuth(token: credentials.access, scheme: .bearer)
+ },
+ )
+ }
+
+ /// Go's family root. The provider appends its own version and path.
+ static func baseURL(for api: String) -> String {
+ switch api {
+ case "anthropic-messages": "\(host)/anthropic"
+ case "google-generative-ai": "\(host)/google"
+ default: "\(host)/openai/v1" // Chat Completions and Responses
+ }
+ }
+
+ /// A catalog entry Apelin can route. An unknown wire falls back to Chat
+ /// Completions, which Console translates for every Go text model.
+ private static func supported(_ api: String?) -> String {
+ guard let api,
+ ["openai-completions", "openai-responses", "anthropic-messages", "google-generative-ai"].contains(api)
+ else { return "openai-completions" }
+ return api
+ }
+
+ /// An id kwwk's pinned catalog does not carry yet. Conservative on
+ /// purpose: text-only, no reasoning, and the compat its known models
+ /// share, so a new id still routes.
+ private static func fallbackModel(id: String, api: String) -> Model {
+ Model(
+ id: id,
+ name: id,
+ api: api,
+ provider: storeID,
+ baseURL: baseURL(for: api),
+ reasoning: false,
+ input: [.text],
+ cost: ModelCost(),
+ contextWindow: 131_072,
+ maxTokens: 32_000,
+ compat: defaultCompat,
+ )
+ }
+
+ private static let defaultCompat: ModelCompat = {
+ var compat = ModelCompat()
+ compat.maxTokensField = "max_tokens"
+ compat.supportsDeveloperRole = false
+ compat.supportsStore = false
+ compat.supportsStrictMode = true
+ return compat
+ }()
+
+ private static func register(api: String) async {
+ let base: any APIProvider
+ switch api {
+ case "openai-responses":
+ // Go documents HTTP/SSE; don't spend a failed WebSocket upgrade
+ // on every conversation.
+ base = OpenAIResponsesProvider(webSocketClient: nil)
+ case "anthropic-messages":
+ base = AnthropicProvider()
+ case "google-generative-ai":
+ base = GoogleGeminiProvider()
+ default:
+ base = OpenAICompletionsProvider()
+ }
+ await APIRegistry.shared.register(OpenCodeGoSessionProvider(base: base), scope: storeID)
+ }
+
+ private static func extra(_ key: String, in credentials: OAuthCredentials) -> String? {
+ guard case let .string(value)? = credentials.extras[key], !value.isEmpty else { return nil }
+ return value
+ }
+}
+
+/// Stamps OpenCode Go's per-request session header on a route whose provider
+/// has no header hook of its own, after `CopilotRequestHeadersProvider`. The
+/// header is the conversation's id; a request with no conversation uses one
+/// process-wide id. Session cleanup and native compaction pass straight
+/// through, so the wrapped route keeps what the registry finds by cast.
+struct OpenCodeGoSessionProvider: APIProvider, APIProviderSessionLifecycle, NativeCompactionProvider {
+ let base: any APIProvider
+
+ var api: String { base.api }
+
+ func stream(model: Model, context: Context, options: StreamOptions?) -> AssistantMessageStream {
+ base.stream(model: model, context: context, options: stamped(options))
+ }
+
+ func closeSession(sessionId: String) async {
+ await (base as? any APIProviderSessionLifecycle)?.closeSession(sessionId: sessionId)
+ }
+
+ func compact(
+ model: Model,
+ context: Context,
+ instructions: String,
+ options: StreamOptions?,
+ ) async throws -> NativeCompactionResult? {
+ guard let native = base as? any NativeCompactionProvider else { return nil }
+ return try await native.compact(
+ model: model,
+ context: context,
+ instructions: instructions,
+ options: stamped(options),
+ )
+ }
+
+ private func stamped(_ options: StreamOptions?) -> StreamOptions {
+ var stamped = options ?? StreamOptions()
+ var headers = stamped.headers ?? [:]
+ let present = headers.keys.contains {
+ $0.caseInsensitiveCompare(OpenCodeGoProvider.sessionHeader) == .orderedSame
+ }
+ if !present {
+ headers[OpenCodeGoProvider.sessionHeader] =
+ stamped.sessionId.flatMap { $0.isEmpty ? nil : $0 }
+ ?? OpenCodeGoProvider.processSession
+ }
+ stamped.headers = headers
+ return stamped
+ }
+}
diff --git a/Packages/ApelinAgent/Sources/ApelinAgent/Session/ProviderConfiguration.swift b/Packages/ApelinAgent/Sources/ApelinAgent/Session/ProviderConfiguration.swift
index 6c39d90a..6986da93 100644
--- a/Packages/ApelinAgent/Sources/ApelinAgent/Session/ProviderConfiguration.swift
+++ b/Packages/ApelinAgent/Sources/ApelinAgent/Session/ProviderConfiguration.swift
@@ -18,8 +18,8 @@ public enum ProviderConfiguration: Sendable, Hashable, Codable {
case githubCopilot(model: String)
/// Any other login kwwk stores, by its store id (`openai-codex`,
/// `kimi-coding`, `google-api-key`, …), registered by kwwk's own
- /// `registerStored`, or Apelin's DeepSeek registration. A nil model is
- /// the provider's own default.
+ /// `registerStored`, or Apelin's DeepSeek and OpenCode Go registrations.
+ /// A nil model is the provider's own default.
case stored(storeID: String, model: String?)
/// A model already registered on kwwk's `APIRegistry` (tests and
/// in-process fakes). Needs no credential.
diff --git a/Packages/ApelinAgent/Sources/ApelinAgent/Session/ProviderRegistration.swift b/Packages/ApelinAgent/Sources/ApelinAgent/Session/ProviderRegistration.swift
index cba50afe..50e2df03 100644
--- a/Packages/ApelinAgent/Sources/ApelinAgent/Session/ProviderRegistration.swift
+++ b/Packages/ApelinAgent/Sources/ApelinAgent/Session/ProviderRegistration.swift
@@ -21,6 +21,9 @@ public enum ProviderRegistration {
ResolvedProviderAuth(token: credentials.access, scheme: .bearer)
})
}
+ if storeID == CredentialProviderID.openCodeGo {
+ return await OpenCodeGoProvider.resolve(credentials: credentials, modelOverride: modelOverride)
+ }
guard let resolved = try await registerStored(storeId: storeID, manager: manager, modelOverride: modelOverride) else {
return nil
}
diff --git a/Packages/ApelinAgent/Sources/ApelinAgentWorker/AgentWorker.swift b/Packages/ApelinAgent/Sources/ApelinAgentWorker/AgentWorker.swift
index c2996217..d5328fb8 100644
--- a/Packages/ApelinAgent/Sources/ApelinAgentWorker/AgentWorker.swift
+++ b/Packages/ApelinAgent/Sources/ApelinAgentWorker/AgentWorker.swift
@@ -228,7 +228,7 @@ public enum AgentWorker {
guard let model else { throw ApelinError(.badRequest, "name the model for \(provider.rawValue)") }
return .openAICompatible(model: model)
case .openAICodex, .cursor, .devin, .kiro, .qoder, .qoderCN, .workBuddy, .workBuddyAI, .kimiCoding, .xai,
- .zai, .zaiCodingCN, .openRouter, .openAI, .google, .deepSeek:
+ .zai, .zaiCodingCN, .openRouter, .openAI, .google, .deepSeek, .openCodeGo:
return .stored(storeID: provider.storeID, model: model)
}
}
diff --git a/Packages/ApelinAgent/Sources/ApelinAgentWorker/SignIn/ProviderModels.swift b/Packages/ApelinAgent/Sources/ApelinAgentWorker/SignIn/ProviderModels.swift
index 10019946..f4f7f8dd 100644
--- a/Packages/ApelinAgent/Sources/ApelinAgentWorker/SignIn/ProviderModels.swift
+++ b/Packages/ApelinAgent/Sources/ApelinAgentWorker/SignIn/ProviderModels.swift
@@ -22,7 +22,10 @@ enum ProviderModels {
/// The kwwk model catalog the account's models come from.
private static func catalogKey(_ provider: AgentProvider, credentials: OAuthCredentials) -> String? {
- provider == .deepSeek || isDeepSeek(credentials) ? "deepseek" : providerDescriptor(forStoreId: provider.storeID)?.catalogKey
+ // kwwk's providerDirectory has no OpenCode Go entry yet, but its
+ // catalog carries the models.
+ if provider == .openCodeGo { return OpenCodeGoProvider.catalogKey }
+ return provider == .deepSeek || isDeepSeek(credentials) ? "deepseek" : providerDescriptor(forStoreId: provider.storeID)?.catalogKey
}
static func list(_ provider: AgentProvider, credentials: OAuthCredentials) -> [String] {
@@ -86,6 +89,7 @@ enum ProviderModels {
}
let defaultURL = provider.keyFields.first { $0.field == .baseURL }?.defaultValue
?? (provider == .openRouter ? "https://openrouter.ai/api/v1" : nil)
+ ?? (provider == .openCodeGo ? OpenCodeGoProvider.modelListBaseURL : nil)
guard let base = configured ?? defaultURL, var url = URLComponents(string: base) else { return nil }
if url.host?.lowercased() == "api.deepseek.com" {
url.path = "/models"
@@ -93,7 +97,8 @@ enum ProviderModels {
let path = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
url.path = "/" + path + (path.hasSuffix("v1") ? "/models" : "/v1/models")
url.path = url.path.replacingOccurrences(of: "//", with: "/")
- } else if provider == .openAI || provider == .openAICompatible || provider == .openRouter || provider == .deepSeek {
+ } else if provider == .openAI || provider == .openAICompatible || provider == .openRouter || provider == .deepSeek
+ || provider == .openCodeGo {
let path = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
url.path = "/" + (path.isEmpty ? "v1" : path) + "/models"
} else if provider == .google {
diff --git a/Packages/ApelinAgent/Sources/ApelinAgentWorker/SignIn/SignInProviders.swift b/Packages/ApelinAgent/Sources/ApelinAgentWorker/SignIn/SignInProviders.swift
index f74fa5db..ced65d59 100644
--- a/Packages/ApelinAgent/Sources/ApelinAgentWorker/SignIn/SignInProviders.swift
+++ b/Packages/ApelinAgent/Sources/ApelinAgentWorker/SignIn/SignInProviders.swift
@@ -6,8 +6,9 @@ import KWWKAI
/// How each provider is signed in to, and the store id its credential
/// is kept under — the one `ProviderConfiguration` reads it back by.
///
-/// The list covers every kwwk login plus Apelin's DeepSeek API account.
-/// Display order is a product choice, independent of registration priority.
+/// The list covers every kwwk login plus Apelin's DeepSeek and OpenCode Go
+/// API accounts. Display order is a product choice, independent of
+/// registration priority.
public extension AgentProvider {
/// Settings groups signed-in accounts, account sign-ins and API keys,
/// preserving this order within each group.
@@ -16,7 +17,7 @@ public extension AgentProvider {
.openAICodex, .anthropicOAuth, .githubCopilot, .cursor,
.workBuddyAI, .workBuddy, .kimiCoding, .xai, .openRouter,
.zai, .zaiCodingCN, .qoder, .qoderCN, .kiro, .devin,
- .openAI, .deepSeek, .google, .anthropic, .openAICompatible,
+ .openAI, .deepSeek, .openCodeGo, .google, .anthropic, .openAICompatible,
]
// New providers remain reachable until their position is chosen.
return order + allCases.filter { !order.contains($0) }
@@ -44,6 +45,7 @@ public extension AgentProvider {
case .openAI: "openai-api-key"
case .google: "google-api-key"
case .deepSeek: CredentialProviderID.deepSeek
+ case .openCodeGo: CredentialProviderID.openCodeGo
}
}
@@ -57,7 +59,7 @@ public extension AgentProvider {
case .anthropicOAuth, .openAICodex, .cursor, .devin, .kiro, .qoder, .qoderCN, .workBuddy, .workBuddyAI,
.zai, .zaiCodingCN, .openRouter:
.browser
- case .anthropic, .openAICompatible, .openAI, .google, .deepSeek:
+ case .anthropic, .openAICompatible, .openAI, .google, .deepSeek, .openCodeGo:
.apiKey
}
}
@@ -95,8 +97,9 @@ struct KeyField: Sendable, Equatable {
/// KWWKCli's API-key login forms (`loginProviders`, the `.apiKey` entries),
/// field for field: what each asks for, what it may leave out, and its
/// defaults. KWWKCli is an executable target, not a library, so this is the
-/// one copy, with Apelin's DeepSeek form added. `extrasKeys` there is every
-/// field but the key, which `AccountDesk.apiKeyCredentials` keeps as extras.
+/// one copy, with Apelin's DeepSeek and OpenCode Go forms added.
+/// `extrasKeys` there is every field but the key, which
+/// `AccountDesk.apiKeyCredentials` keeps as extras.
enum SignInKeyForms {
struct Form: Sendable {
var provider: AgentProvider
@@ -120,6 +123,9 @@ enum SignInKeyForms {
KeyField(field: .apiKey, required: true, placeholder: "sk-…"),
KeyField(field: .baseURL, required: false, defaultValue: DeepSeekProvider.baseURL),
]),
+ Form(provider: .openCodeGo, fields: [
+ KeyField(field: .apiKey, required: true, placeholder: "oc_sk_…"),
+ ]),
Form(provider: .openRouter, fields: [
KeyField(field: .apiKey, required: true, placeholder: "sk-or-v1-…"),
KeyField(field: .defaultModel, required: false, placeholder: "anthropic/claude-sonnet-5"),
diff --git a/Packages/ApelinAgent/Tests/ApelinAgentLiveTests/OpenCodeGoLiveTests.swift b/Packages/ApelinAgent/Tests/ApelinAgentLiveTests/OpenCodeGoLiveTests.swift
new file mode 100644
index 00000000..1d4ec5d0
--- /dev/null
+++ b/Packages/ApelinAgent/Tests/ApelinAgentLiveTests/OpenCodeGoLiveTests.swift
@@ -0,0 +1,83 @@
+@testable import ApelinAgent
+import Foundation
+import KWWKAI
+import Testing
+
+/// Talks to OpenCode Go for real. Skipped unless APELIN_LIVE_OAUTH_PATH
+/// names a kwwk-format credential file with an `opencode-go` entry. The file
+/// is read only through kwwk's own loader (`OAuthStore(url:)`), its entry is
+/// handed to the session in memory, and nothing from it is printed or
+/// written anywhere.
+private enum OpenCodeGoLiveCredential {
+ static var present: Bool {
+ guard let path = ProcessInfo.processInfo.environment["APELIN_LIVE_OAUTH_PATH"],
+ let data = FileManager.default.contents(atPath: path),
+ let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ else { return false }
+ return root["opencode-go"] != nil
+ }
+}
+
+@Suite("Live: OpenCode Go", .serialized, .enabled(if: OpenCodeGoLiveCredential.present))
+struct OpenCodeGoLiveTests {
+ /// Both a Chat Completions model and an Anthropic Messages model, so the
+ /// session header and two of Go's wires are exercised.
+ static let models = ["glm-5.2", "claude-haiku-5-5"]
+
+ @Test(arguments: models)
+ func `one tool-call round trip on each Go wire`(model: String) async throws {
+ let path = try #require(ProcessInfo.processInfo.environment["APELIN_LIVE_OAUTH_PATH"])
+ let store = try OAuthStore(url: URL(fileURLWithPath: path))
+ let entry = try #require(await store.credentials(for: CredentialProviderID.openCodeGo), "no opencode-go entry")
+ let credentials = DeliveredCredentialSource([CredentialProviderID.openCodeGo: entry])
+
+ let home = FileManager.default.temporaryDirectory.appendingPathComponent("apelin-live-\(UUID().uuidString)", isDirectory: true)
+ defer { try? FileManager.default.removeItem(at: home) }
+ let dataRoot = ApelinDataRoot(home: home)
+ let project = try ProjectIdentity(id: "live", name: ProjectName("Live"))
+ let projectURL = dataRoot.project(named: project.name)
+ try FileManager.default.createDirectory(at: projectURL, withIntermediateDirectories: true)
+ let word = "pelican-\(Int.random(in: 1000 ... 9999))"
+ try "\(word)\n".write(to: projectURL.appendingPathComponent("secret-word.txt"), atomically: true, encoding: .utf8)
+
+ let session = try await AgentSession(
+ configuration: AgentSessionConfiguration(
+ project: project,
+ provider: .stored(storeID: CredentialProviderID.openCodeGo, model: model),
+ approval: ApprovalPolicy(mode: .confirm),
+ dataRoot: dataRoot,
+ maxTurns: 4,
+ ),
+ backend: NoHostBackend(),
+ digests: NoDigests(),
+ credentials: credentials,
+ )
+ let stream = try await session.events(afterSeq: 0)
+ try await session.prompt("Use the file_read tool to read secret-word.txt, then reply with only the word it contains.")
+
+ var toolCalls: [String] = []
+ var finalText = ""
+ var errors: [String] = []
+ var finish: FinishReason?
+ for await record in stream {
+ switch record.event {
+ case let .toolCallStarted(call): toolCalls.append(call.name)
+ case let .messageFinished(text): if !text.isEmpty {
+ finalText = text
+ }
+ case let .error(message): errors.append(message)
+ case let .finished(reason): finish = reason
+ default: break
+ }
+ if finish != nil {
+ break
+ }
+ }
+ await session.close()
+
+ #expect(errors.isEmpty, "\(model): \(errors)")
+ #expect(finish == .completed, "\(model) did not finish")
+ #expect(toolCalls.contains("file_read"), "\(model) made no tool call")
+ #expect(finalText.contains(word), "\(model) did not read the word")
+ }
+}
diff --git a/Packages/ApelinAgent/Tests/ApelinAgentTests/OpenCodeGoProviderTests.swift b/Packages/ApelinAgent/Tests/ApelinAgentTests/OpenCodeGoProviderTests.swift
new file mode 100644
index 00000000..77c3f7b6
--- /dev/null
+++ b/Packages/ApelinAgent/Tests/ApelinAgentTests/OpenCodeGoProviderTests.swift
@@ -0,0 +1,177 @@
+@testable import ApelinAgent
+import Foundation
+import KWWKAI
+import Testing
+
+@Suite("OpenCode Go registration and session header", .serialized)
+struct OpenCodeGoProviderTests {
+ private static let key = OAuthCredentials(access: "oc_sk-key", refresh: "", expires: .max)
+
+ @Test(arguments: [
+ ("glm-5.2", "openai-completions", "https://opencode.ai/inference/go/openai/v1"),
+ ("claude-haiku-5-5", "anthropic-messages", "https://opencode.ai/inference/go/anthropic"),
+ ("grok-4.7", "openai-responses", "https://opencode.ai/inference/go/openai/v1"),
+ ("minimax-m3", "anthropic-messages", "https://opencode.ai/inference/go/anthropic"),
+ ("muse-spark-1.3-contributor", "openai-responses", "https://opencode.ai/inference/go/openai/v1"),
+ ])
+ func `catalog models keep their wire on Go's family base`(
+ id: String,
+ api: String,
+ baseURL: String,
+ ) async {
+ let resolved = await OpenCodeGoProvider.resolve(credentials: Self.key, modelOverride: id)
+ #expect(resolved.model.id == id)
+ #expect(resolved.model.api == api)
+ #expect(resolved.model.provider == CredentialProviderID.openCodeGo)
+ #expect(resolved.model.baseURL == baseURL)
+ }
+
+ @Test
+ func `DeepSeek's catalog capabilities survive the Go registration`() async {
+ let resolved = await OpenCodeGoProvider.resolve(credentials: Self.key, modelOverride: "deepseek-v4.1-flash")
+ #expect(resolved.model.compat?.thinkingFormat == "deepseek")
+ #expect(resolved.model.input.contains(.image))
+ #expect(resolved.model.contextWindow == 1_000_000)
+ }
+
+ @Test
+ func `an unknown model falls back to Chat Completions`() async {
+ let resolved = await OpenCodeGoProvider.resolve(credentials: Self.key, modelOverride: "future-model")
+ #expect(resolved.model.api == "openai-completions")
+ #expect(resolved.model.baseURL == "https://opencode.ai/inference/go/openai/v1")
+ #expect(resolved.model.reasoning == false)
+ #expect(resolved.model.input == [.text])
+ }
+
+ @Test
+ func `a stored default model is used when the caller names none`() async {
+ let credentials = OAuthCredentials(access: "key", refresh: "", expires: .max, extras: [
+ "defaultModel": .string("claude-haiku-5-5"),
+ ])
+ let resolved = await OpenCodeGoProvider.resolve(credentials: credentials, modelOverride: nil)
+ #expect(resolved.model.id == "claude-haiku-5-5")
+ #expect(resolved.model.api == "anthropic-messages")
+ }
+
+ @Test
+ func `the auth resolver carries the key as a bearer token`() async throws {
+ let resolved = await OpenCodeGoProvider.resolve(credentials: Self.key, modelOverride: "glm-5.2")
+ let auth = try #require(try await resolved.authResolver?(resolved.model, nil))
+ #expect(auth.token == "oc_sk-key")
+ #expect(auth.scheme == .bearer)
+ }
+
+ @Test
+ func `Gemini is registered on its own family root`() {
+ #expect(OpenCodeGoProvider.baseURL(for: "google-generative-ai")
+ == "https://opencode.ai/inference/go/google")
+ }
+
+ @Test
+ func `every request names its conversation`() async throws {
+ let capture = RequestCapture()
+ let wrapped = OpenCodeGoSessionProvider(base: OpenAICompletionsProvider(client: capture))
+ var model = try #require(ModelsCatalog.model(provider: "opencode-go", id: "glm-5.2"))
+ model.baseURL = OpenCodeGoProvider.baseURL(for: model.api)
+
+ let stream = wrapped.stream(
+ model: model,
+ context: Context(messages: [.user(UserMessage(text: "hi"))]),
+ options: StreamOptions(
+ maxTokens: 32,
+ sessionId: "ses_test",
+ resolvedAuth: ResolvedProviderAuth(token: "oc_sk-key", scheme: .bearer),
+ verbose: false,
+ ),
+ )
+ for await _ in stream {}
+ #expect(await stream.result().stopReason == .stop)
+
+ let request = try #require(await capture.request)
+ #expect(request.url.absoluteString == "https://opencode.ai/inference/go/openai/v1/chat/completions")
+ #expect(request.headers["Authorization"] == "Bearer oc_sk-key")
+ #expect(header(request, "x-opencode-session") == "ses_test")
+ }
+
+ @Test
+ func `a request with no conversation uses one process-wide id`() async throws {
+ let capture = RequestCapture()
+ let wrapped = OpenCodeGoSessionProvider(base: OpenAICompletionsProvider(client: capture))
+ var model = try #require(ModelsCatalog.model(provider: "opencode-go", id: "glm-5.2"))
+ model.baseURL = OpenCodeGoProvider.baseURL(for: model.api)
+
+ let stream = wrapped.stream(
+ model: model,
+ context: Context(messages: [.user(UserMessage(text: "hi"))]),
+ options: StreamOptions(
+ maxTokens: 32,
+ resolvedAuth: ResolvedProviderAuth(token: "oc_sk-key", scheme: .bearer),
+ verbose: false,
+ ),
+ )
+ for await _ in stream {}
+
+ let request = try #require(await capture.request)
+ #expect(header(request, "x-opencode-session") == OpenCodeGoProvider.processSession)
+ }
+
+ @Test
+ func `a caller's session header wins`() async throws {
+ let capture = RequestCapture()
+ let wrapped = OpenCodeGoSessionProvider(base: OpenAICompletionsProvider(client: capture))
+ var model = try #require(ModelsCatalog.model(provider: "opencode-go", id: "glm-5.2"))
+ model.baseURL = OpenCodeGoProvider.baseURL(for: model.api)
+
+ let stream = wrapped.stream(
+ model: model,
+ context: Context(messages: [.user(UserMessage(text: "hi"))]),
+ options: StreamOptions(
+ maxTokens: 32,
+ sessionId: "ses_real",
+ headers: ["X-OpenCode-Session": "caller"],
+ resolvedAuth: ResolvedProviderAuth(token: "oc_sk-key", scheme: .bearer),
+ verbose: false,
+ ),
+ )
+ for await _ in stream {}
+
+ let request = try #require(await capture.request)
+ #expect(header(request, "x-opencode-session") == "caller")
+ }
+
+ private func header(_ request: RequestCapture.Request, _ name: String) -> String? {
+ request.headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value
+ }
+}
+
+private actor RequestCapture: HTTPClient {
+ struct Request: Sendable {
+ var url: URL
+ var headers: [String: String]
+ var body: Data?
+ }
+
+ var request: Request?
+
+ func stream(
+ url: URL,
+ method: String,
+ headers: [String: String],
+ body: Data?,
+ cancellation _: CancellationHandle?,
+ ) async throws -> (HTTPURLResponse, AsyncThrowingStream) {
+ #expect(method == "POST")
+ request = Request(url: url, headers: headers, body: body)
+ let response = try #require(HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: "HTTP/1.1",
+ headerFields: ["content-type": "text/event-stream"],
+ ))
+ let reply = "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"OK\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"
+ return (response, AsyncThrowingStream { continuation in
+ continuation.yield(Data(reply.utf8))
+ continuation.finish()
+ })
+ }
+}
diff --git a/Packages/ApelinAgent/Tests/ApelinAgentWorkerTests/AccountDeskTests.swift b/Packages/ApelinAgent/Tests/ApelinAgentWorkerTests/AccountDeskTests.swift
index cf537fe9..291413c8 100644
--- a/Packages/ApelinAgent/Tests/ApelinAgentWorkerTests/AccountDeskTests.swift
+++ b/Packages/ApelinAgent/Tests/ApelinAgentWorkerTests/AccountDeskTests.swift
@@ -556,6 +556,35 @@ struct AccountDeskTests {
#expect(try await store.credentials(for: CredentialProviderID.openAICompatible) == other)
}
+ @Test
+ func `OpenCode Go signs in with a key alone and lists its catalog models`() async throws {
+ let other = OAuthCredentials(access: "other-SECRET", refresh: "", expires: .max)
+ let store = DeliveredCredentialSource([CredentialProviderID.openAICompatible: other])
+ let desk = AccountDesk(store: store, login: Self.neverCalled, listener: Self.silent, verifyKey: { provider, key, _ in
+ #expect(provider == .openCodeGo)
+ #expect(key.access == "opencode-go-SECRET")
+ })
+ let recorder = SignInRecorder()
+ let task = TaskID("opencode-go-sign-in")
+ try await desk.start(AgentSignInRequest(provider: .openCodeGo), taskID: task, reporter: recorder.reporter)
+ try await recorder.waitFor { !$0.events.isEmpty }
+ #expect(recorder.events.first?.kind == "input")
+ #expect(AgentProvider.openCodeGo.keyFields.count == 1)
+ #expect(AgentProvider.openCodeGo.keyFields.first?.field == .apiKey)
+ try await desk.respond(AgentSignInRespondRequest(taskID: task, fields: [.apiKey: "opencode-go-SECRET"]))
+ try await recorder.waitFor { $0.state != nil }
+ #expect(recorder.state == .succeeded)
+ let account = try #require(await desk.list().accounts.first { $0.provider == .openCodeGo })
+ #expect(account.signedIn)
+ #expect(account.models?.contains("glm-5.2") == true)
+ #expect(account.models?.contains("claude-haiku-5-5") == true)
+ #expect(account.reasoningEfforts?["glm-5.2"]?.contains(.high) == true)
+ #expect(!recorder.transcript.contains("SECRET"))
+ try await desk.signOut(.openCodeGo)
+ #expect(try await store.credentials(for: CredentialProviderID.openCodeGo) == nil)
+ #expect(try await store.credentials(for: CredentialProviderID.openAICompatible) == other)
+ }
+
/// The test that fails when kwwk gains a login: every subscription
/// `OAuthSubscriptions.login` runs has a provider here that signs in by
/// a flow (not a typed key). Display order is Apelin's own.
@@ -568,18 +597,18 @@ struct AccountDeskTests {
}
#expect(Array(AgentProvider.signInOrder.prefix(4)) == [.openAICodex, .anthropicOAuth, .githubCopilot, .cursor])
#expect(AgentProvider.signInOrder.filter { $0.signInMethod == .apiKey }
- == [.openAI, .deepSeek, .google, .anthropic, .openAICompatible])
+ == [.openAI, .deepSeek, .openCodeGo, .google, .anthropic, .openAICompatible])
}
/// Every login kwwk can store and register for a session
/// (`providerDirectory`, the list `registerStored` serves) has a
- /// provider here; Apelin's own registration adds DeepSeek.
+ /// provider here; Apelin's own registration adds DeepSeek and OpenCode Go.
@Test
func `every stored provider is registered by kwwk or Apelin`() {
let kwwk = Set(providerDirectory.map(\.storeId))
let apelin = Set(AgentProvider.allCases.map(\.storeID))
#expect(kwwk.subtracting(apelin).isEmpty, "kwwk stores \(kwwk.subtracting(apelin).sorted()) with no provider")
- #expect(apelin.subtracting(kwwk) == [CredentialProviderID.deepSeek])
+ #expect(apelin.subtracting(kwwk) == [CredentialProviderID.deepSeek, CredentialProviderID.openCodeGo])
// A store id kwwk keeps as a typed key and does not sign in to is
// a key form here.
let subscriptions = Set(OAuthSubscriptions.providers.map(\.id))
@@ -593,7 +622,7 @@ struct AccountDeskTests {
/// form only the fields `agentSignInRespond` knows.
@Test
func `each key form asks for a key first, as KWWKCli's does`() {
- #expect(SignInKeyForms.all.map(\.provider) == [.anthropic, .openAI, .google, .deepSeek, .openRouter, .openAICompatible])
+ #expect(SignInKeyForms.all.map(\.provider) == [.anthropic, .openAI, .google, .deepSeek, .openCodeGo, .openRouter, .openAICompatible])
for form in SignInKeyForms.all {
#expect(form.fields.first == KeyField(field: .apiKey, required: true, placeholder: form.fields.first?.placeholder))
#expect(form.fields.filter(\.secure).count == 1)
diff --git a/Packages/ApelinAgent/Tests/ApelinAgentWorkerTests/ProviderKeyVerificationTests.swift b/Packages/ApelinAgent/Tests/ApelinAgentWorkerTests/ProviderKeyVerificationTests.swift
index 54fad123..3dd1a320 100644
--- a/Packages/ApelinAgent/Tests/ApelinAgentWorkerTests/ProviderKeyVerificationTests.swift
+++ b/Packages/ApelinAgent/Tests/ApelinAgentWorkerTests/ProviderKeyVerificationTests.swift
@@ -7,7 +7,7 @@ import Testing
@Suite(.timeLimit(.minutes(1)))
struct ProviderKeyVerificationTests {
- @Test(arguments: [AgentProvider.openAICompatible, .deepSeek])
+ @Test(arguments: [AgentProvider.openAICompatible, .deepSeek, .openCodeGo])
func `verification sends an authenticated model request and rejects HTTP errors and empty answers`(provider: AgentProvider) async throws {
let answer = "data: {\"id\":\"test\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"OK\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"
let empty = "data: {\"id\":\"test\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"
diff --git a/Packages/ApelinAgent/Tests/ApelinAgentWorkerTests/ProviderModelsTests.swift b/Packages/ApelinAgent/Tests/ApelinAgentWorkerTests/ProviderModelsTests.swift
index d3227562..64ada90f 100644
--- a/Packages/ApelinAgent/Tests/ApelinAgentWorkerTests/ProviderModelsTests.swift
+++ b/Packages/ApelinAgent/Tests/ApelinAgentWorkerTests/ProviderModelsTests.swift
@@ -5,6 +5,22 @@ import KWWKAI
import Testing
struct ProviderModelsTests {
+ @Test
+ func `OpenCode Go offers kwwk's catalog and its own discovery endpoint`() throws {
+ let key = OAuthCredentials(access: "key", refresh: "", expires: .max)
+ let models = ProviderModels.list(.openCodeGo, credentials: key)
+ #expect(models.contains("glm-5.2"))
+ #expect(models.contains("claude-haiku-5-5"))
+ #expect(models.contains("deepseek-v4.1-flash"))
+ #expect(ProviderModels.reasoningEfforts(.openCodeGo, credentials: key)["glm-5.2"]?.contains(.high) == true)
+ let request = try #require(ProviderModels.modelRequest(.openCodeGo, credentials: key))
+ #expect(request.url?.absoluteString == "https://opencode.ai/inference/go/v1/models")
+ #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer key")
+ #expect(request.url?.query == nil)
+ #expect(try AgentWorker.provider(.init(provider: .openCodeGo))
+ == .stored(storeID: CredentialProviderID.openCodeGo, model: nil))
+ }
+
@Test
func `DeepSeek offers its own models and discovery with only an API key`() throws {
let key = OAuthCredentials(access: "key", refresh: "", expires: .max)
diff --git a/Packages/ApelinKit/Sources/ApelinProtocol/Operations/Agent.swift b/Packages/ApelinKit/Sources/ApelinProtocol/Operations/Agent.swift
index eb6b2174..dbf4cc4b 100644
--- a/Packages/ApelinKit/Sources/ApelinProtocol/Operations/Agent.swift
+++ b/Packages/ApelinKit/Sources/ApelinProtocol/Operations/Agent.swift
@@ -169,6 +169,9 @@ public enum AgentProvider: String, Codable, Sendable {
case google
/// DeepSeek's API with its own API key (`deepseek-api-key`).
case deepSeek
+ /// OpenCode Go, opencode.ai's coding subscription, by an API key from
+ /// its Console (`opencode-go`).
+ case openCodeGo
}
extension AgentProvider: CaseIterable {}
diff --git a/README.md b/README.md
index 58303187..5518a372 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ Apelin is an IDE for iPhone and iPad running custom firmware on iOS and iPadOS 1
- Downloads and checks the iPhoneOS SDKs, or imports one from a file
- Templates for tweaks, apps, command-line tools and preference bundles, or an empty workspace that keeps your project's own build script
- Builds, signs, packages and installs through Irisin, then resprings and checks that your tweak loaded
-- An agent that edits your project, builds and installs it, and reads logs and crash reports. Sign in with GitHub Copilot, Claude, ChatGPT, OpenRouter and many other providers, or use an API key
+- An agent that edits your project, builds and installs it, and reads logs and crash reports. Sign in with GitHub Copilot, Claude, ChatGPT, OpenRouter, OpenCode Go and many other providers, or use an API key
- Choose how much the agent may do on its own: Strict asks before anything beyond a project edit, and every command as root asks unless you turn on Full
- A code editor, a terminal and git, with Sign In with GitHub
- Publish your packages to your own package repository on GitHub Pages, and share it as a card anyone can scan to add it in Irisin