Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 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
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,20 @@ struct DoubaoProviderImplementation: ProviderImplementation {
@MainActor
func observeSettings(_ settings: SettingsStore) {
_ = settings.doubaoAPIToken
_ = settings.doubaoSecretAccessKey
_ = settings.doubaoRegion
}

@MainActor
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[
ProviderSettingsFieldDescriptor(
id: "doubao-api-token",
title: "API key",
subtitle: "Stored in ~/.codexbar/config.json. Get your API key from the Volcengine "
+ "Ark console.",
title: "API key / Access key ID",
subtitle: "Use a Volcengine access key ID with the secret field for Coding Plan usage, "
+ "or leave the secret blank to use an Ark API key.",
kind: .secure,
placeholder: "ark-...",
placeholder: "ark-... or AKLT...",
binding: context.stringBinding(\.doubaoAPIToken),
actions: [
ProviderSettingsActionDescriptor(
Expand All @@ -35,6 +37,26 @@ struct DoubaoProviderImplementation: ProviderImplementation {
],
isVisible: nil,
onActivate: nil),
ProviderSettingsFieldDescriptor(
id: "doubao-secret-access-key",
title: "Secret access key",
subtitle: "Volcengine secret access key for the signed Coding Plan usage API.",
kind: .secure,
placeholder: "",
binding: context.stringBinding(\.doubaoSecretAccessKey),
actions: [],
isVisible: nil,
onActivate: nil),
ProviderSettingsFieldDescriptor(
id: "doubao-region",
title: "Region",
subtitle: "Volcengine Ark region. Defaults to cn-beijing.",
kind: .plain,
placeholder: DoubaoSettingsReader.defaultRegion,
binding: context.stringBinding(\.doubaoRegion),
actions: [],
isVisible: nil,
onActivate: nil),
]
}
}
20 changes: 20 additions & 0 deletions Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,24 @@ extension SettingsStore {
self.logSecretUpdate(provider: .doubao, field: "apiKey", value: newValue)
}
}

var doubaoSecretAccessKey: String {
get { self.configSnapshot.providerConfig(for: .doubao)?.sanitizedSecretKey ?? "" }
set {
self.updateProviderConfig(provider: .doubao) { entry in
entry.secretKey = self.normalizedConfigValue(newValue)
}
self.logSecretUpdate(provider: .doubao, field: "secretAccessKey", value: newValue)
}
}

var doubaoRegion: String {
get { self.configSnapshot.providerConfig(for: .doubao)?.sanitizedRegion ?? "" }
set {
self.updateProviderConfig(provider: .doubao) { entry in
entry.region = self.normalizedConfigValue(newValue)
}
self.logProviderModeChange(provider: .doubao, field: "region", value: newValue)
}
}
}
7 changes: 4 additions & 3 deletions Sources/CodexBarCore/Config/CodexBarConfigValidation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ public enum CodexBarConfigValidator {
private static func validateSecretKey(_ entry: ProviderConfig, issues: inout [CodexBarConfigIssue]) {
guard let secretKey = entry.secretKey,
!secretKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
entry.id != .bedrock
entry.id != .bedrock,
entry.id != .doubao
else {
return
}
Expand All @@ -190,7 +191,7 @@ public enum CodexBarConfigValidator {
provider: entry.id,
field: "secretKey",
code: "secret_key_unused",
message: "secretKey is set but only bedrock uses secretKey."))
message: "secretKey is set but only bedrock and doubao use secretKey."))
}

private static func validateZaiTeamContext(_ entry: ProviderConfig, issues: inout [CodexBarConfigIssue]) {
Expand Down Expand Up @@ -277,7 +278,7 @@ public enum CodexBarConfigValidator {
isValid: MoonshotRegion(rawValue: region) != nil,
displayName: "Moonshot",
issues: &issues)
case .bedrock:
case .bedrock, .doubao:
break
default:
issues.append(CodexBarConfigIssue(
Expand Down
60 changes: 60 additions & 0 deletions Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ public enum ProviderConfigEnvironment {
self.applyAzureOpenAIOverrides(base: base, config: config)
case .kimi:
self.applyKimiOverrides(base: base, config: config)
case .doubao:
self.applyDoubaoOverrides(base: base, config: config)
default:
nil
}
Expand Down Expand Up @@ -285,6 +287,64 @@ public enum ProviderConfigEnvironment {
return env
}

private static func applyDoubaoOverrides(
base: [String: String],
config: ProviderConfig?) -> [String: String]
{
guard let config else { return base }
var env = base
let apiKey = config.sanitizedAPIKey
let secretKey = config.sanitizedSecretKey

if let apiKey, self.doubaoAccessKeyID(from: apiKey) == nil {
self.clearDoubaoCodingPlanCredentialKeys(in: &env)
env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] = apiKey
if let region = config.sanitizedRegion {
env[DoubaoSettingsReader.regionEnvironmentKeys[0]] = region
}
return env
}

let accessKeyID = self.doubaoAccessKeyID(from: apiKey) ?? DoubaoSettingsReader.accessKeyID(environment: base)
let secretAccessKey = secretKey ?? DoubaoSettingsReader.secretAccessKey(environment: base)

if let accessKeyID, let secretAccessKey {
env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] = accessKeyID
env[DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]] = secretAccessKey
if let region = config.sanitizedRegion ?? self.firstDoubaoRegionValue(in: base) {
env[DoubaoSettingsReader.regionEnvironmentKeys[0]] = region
}
return env
}

if let region = config.sanitizedRegion {
env[DoubaoSettingsReader.regionEnvironmentKeys[0]] = region
}
return env
}

private static func doubaoAccessKeyID(from apiKey: String?) -> String? {
guard let apiKey, apiKey.hasPrefix("AKLT") else { return nil }
return apiKey
}

private static func clearDoubaoCodingPlanCredentialKeys(in environment: inout [String: String]) {
for key in DoubaoSettingsReader.accessKeyIDEnvironmentKeys {
environment.removeValue(forKey: key)
}
for key in DoubaoSettingsReader.secretAccessKeyEnvironmentKeys {
environment.removeValue(forKey: key)
}
}

private static func firstDoubaoRegionValue(in environment: [String: String]) -> String? {
for key in DoubaoSettingsReader.regionEnvironmentKeys {
guard let value = DoubaoSettingsReader.cleaned(environment[key]) else { continue }
return value
}
return nil
}

private static func applyAzureOpenAIOverrides(
base: [String: String],
config: ProviderConfig?) -> [String: String]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ public enum DoubaoProviderDescriptor {
metadata: ProviderMetadata(
id: .doubao,
displayName: "Doubao",
sessionLabel: "Requests",
weeklyLabel: "Rate limit",
opusLabel: nil,
supportsOpus: false,
sessionLabel: "5-hour",
Comment thread
LeoLin990405 marked this conversation as resolved.
weeklyLabel: "Weekly",
opusLabel: "Monthly",
supportsOpus: true,
supportsCredits: false,
creditsHint: "",
toggleTitle: "Show Doubao usage",
Expand All @@ -30,16 +30,72 @@ public enum DoubaoProviderDescriptor {
tokenCost: ProviderTokenCostConfig(
supportsTokenCost: false,
noDataMessage: { "Doubao cost summary is not available." }),
fetchPlan: .apiToken(
strategyID: "doubao.api",
resolveToken: { ProviderTokenResolver.doubaoToken(environment: $0) },
missingCredentialsError: { DoubaoUsageError.missingCredentials },
loadUsage: { apiKey, _ in
try await DoubaoUsageFetcher.fetchUsage(apiKey: apiKey).toUsageSnapshot()
}),
fetchPlan: ProviderFetchPlan(
sourceModes: [.auto, .api],
pipeline: ProviderFetchPipeline(resolveStrategies: { _ in
[DoubaoAPIFetchStrategy()]
})),
cli: ProviderCLIConfig(
name: "doubao",
aliases: ["volcengine", "ark", "bytedance"],
versionDetector: nil))
}
}

struct DoubaoAPIFetchStrategy: ProviderFetchStrategy {
let id: String = "doubao.api"
let kind: ProviderFetchKind = .apiToken
private let codingPlanUsageLoader: @Sendable (DoubaoCodingPlanCredentials) async throws -> DoubaoUsageSnapshot
private let arkUsageLoader: @Sendable (String) async throws -> DoubaoUsageSnapshot

init(
codingPlanUsageLoader: @escaping @Sendable (DoubaoCodingPlanCredentials) async throws
-> DoubaoUsageSnapshot = { credentials in
try await DoubaoUsageFetcher.fetchCodingPlanUsage(credentials: credentials)
},
arkUsageLoader: @escaping @Sendable (String) async throws -> DoubaoUsageSnapshot = { apiKey in
try await DoubaoUsageFetcher.fetchUsage(apiKey: apiKey)
})
{
self.codingPlanUsageLoader = codingPlanUsageLoader
self.arkUsageLoader = arkUsageLoader
}

func isAvailable(_ context: ProviderFetchContext) async -> Bool {
DoubaoSettingsReader.codingPlanCredentials(environment: context.env) != nil ||
ProviderTokenResolver.doubaoToken(environment: context.env) != nil
}

func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
let apiKey = ProviderTokenResolver.doubaoToken(environment: context.env)
if let credentials = DoubaoSettingsReader.codingPlanCredentials(environment: context.env) {
do {
let usage = try await self.codingPlanUsageLoader(credentials)
return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api")
} catch {
if Self.isCancellation(error) {
throw error
}
guard let apiKey else {
throw error
}
let usage = try await self.arkUsageLoader(apiKey)
return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api")
}
}

guard let apiKey else {
throw DoubaoUsageError.missingCredentials
}
let usage = try await self.arkUsageLoader(apiKey)
return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api")
}

func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
false
}

private static func isCancellation(_ error: Error) -> Bool {
error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled
}
}
73 changes: 60 additions & 13 deletions Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,78 @@ public struct DoubaoSettingsReader: Sendable {
"VOLCENGINE_API_KEY",
"DOUBAO_API_KEY",
]
public static let accessKeyIDEnvironmentKeys = [
"VOLCENGINE_ACCESS_KEY_ID",
"VOLCENGINE_ACCESS_KEY",
"DOUBAO_ACCESS_KEY_ID",
]
Comment thread
LeoLin990405 marked this conversation as resolved.
public static let secretAccessKeyEnvironmentKeys = [
"VOLCENGINE_SECRET_ACCESS_KEY",
"VOLCENGINE_SECRET_KEY",
"VOLCENGINE_ACCESS_KEY_SECRET",
"DOUBAO_SECRET_ACCESS_KEY",
]
Comment thread
LeoLin990405 marked this conversation as resolved.
public static let regionEnvironmentKeys = [
"VOLCENGINE_REGION",
"VOLCENGINE_REGION_ID",
"DOUBAO_REGION",
]
public static let defaultRegion = "cn-beijing"

public static func apiKey(
environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
{
for key in self.apiKeyEnvironmentKeys {
guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty
else {
continue
}
let cleaned = Self.cleaned(raw)
if !cleaned.isEmpty {
return cleaned
}
self.firstValue(in: environment, keys: self.apiKeyEnvironmentKeys)
}

public static func accessKeyID(
environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
{
self.firstValue(in: environment, keys: self.accessKeyIDEnvironmentKeys)
}

public static func secretAccessKey(
environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
{
self.firstValue(in: environment, keys: self.secretAccessKeyEnvironmentKeys)
}

public static func region(environment: [String: String] = ProcessInfo.processInfo.environment) -> String {
self.firstValue(in: environment, keys: self.regionEnvironmentKeys) ?? self.defaultRegion
}

public static func codingPlanCredentials(
environment: [String: String] = ProcessInfo.processInfo.environment) -> DoubaoCodingPlanCredentials?
{
guard let accessKeyID = self.accessKeyID(environment: environment),
let secretAccessKey = self.secretAccessKey(environment: environment)
else {
return nil
}
return DoubaoCodingPlanCredentials(
accessKeyID: accessKeyID,
secretAccessKey: secretAccessKey,
region: self.region(environment: environment))
}

private static func firstValue(in environment: [String: String], keys: [String]) -> String? {
for key in keys {
guard let cleaned = self.cleaned(environment[key]) else { continue }
return cleaned
}
return nil
}

private static func cleaned(_ raw: String) -> String {
var value = raw
static func cleaned(_ raw: String?) -> String? {
guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
}
if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
(value.hasPrefix("'") && value.hasSuffix("'"))
{
value = String(value.dropFirst().dropLast())
}
return value.trimmingCharacters(in: .whitespacesAndNewlines)
value = value.trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
Loading