Skip to content
Closed
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
20 changes: 18 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")
}
}

Expand All @@ -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")
}
}

Expand Down Expand Up @@ -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.")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"images" : [
{ "filename" : "opencode-go.svg", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "preserves-vector-representation" : true }
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
87 changes: 87 additions & 0 deletions Apelin/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
@@ -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" : {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading