Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 0.47.1 — Unreleased

### Added
- z.ai/GLM: route BigModel aliases and relay-file keys only to China endpoints, reject canonical cross-region overrides before bearer auth, and keep Kimi browser import disabled when Cookie Source is Off (#2351). Thanks @Leehow!
- Kimi: enrich Code API and CLI usage with the monthly membership pool from a signed-in Kimi Desktop session, using WAL-safe read-only cookie access (#2351). Thanks @Leehow!
- Kimi/GLM: distinguish Kimi Code from the regional Open Platform, bind China and international keys to their issuing hosts, and show GLM Coding Plan's 5-hour window as primary with MCP separate (#2351). Thanks @Leehow!
- Provider plugins: declarative detail rows/charts plus bundled JavaScript conversions for OpenAI, z.ai, OpenRouter, Poe, and ClawRouter behind `CODEXBAR_JS_PROVIDERS=1`.
Expand Down
38 changes: 34 additions & 4 deletions Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ struct ZaiProviderImplementation: ProviderImplementation {

@MainActor
func isAvailable(context: ProviderAvailabilityContext) -> Bool {
if ZaiSettingsReader.apiToken(environment: context.environment) != nil {
if ZaiSettingsReader.apiToken(
for: context.settings.zaiAPIRegion,
environment: context.environment) != nil
{
return true
}
context.settings.ensureZaiAPITokenLoaded()
Expand All @@ -45,7 +48,8 @@ struct ZaiProviderImplementation: ProviderImplementation {
ProviderSettingsPickerDescriptor(
id: "zai-api-region",
title: "API region",
subtitle: "Use BigModel for the China mainland endpoints (open.bigmodel.cn).",
subtitle: "Global uses api.z.ai. China mainland uses open.bigmodel.cn with a BigModel/GLM key; " +
"the two key families are not interchangeable.",
binding: binding,
options: options,
isVisible: nil,
Expand All @@ -54,7 +58,33 @@ struct ZaiProviderImplementation: ProviderImplementation {
}

@MainActor
func settingsFields(context _: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[]
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[
ProviderSettingsFieldDescriptor(
id: "zai-api-key",
title: "API key",
subtitle: "Use a key issued for the selected region. China also reads BIGMODEL_API_KEY, " +
"ZHIPU_API_KEY, GLM_API_KEY, or ~/.coding-relay/glm-api-key.",
kind: .secure,
placeholder: "Paste z.ai / GLM API key…",
binding: context.stringBinding(\.zaiAPIToken),
actions: [
ProviderSettingsActionDescriptor(
id: "zai-open-api-keys",
title: "Open regional API keys",
style: .link,
isVisible: nil,
perform: {
let url = context.settings.zaiAPIRegion == .bigmodelCN
? URL(string: "https://bigmodel.cn/usercenter/proj-mgmt/apikeys")
: URL(string: "https://z.ai/manage-apikey/apikey")
if let url {
NSWorkspace.shared.open(url)
}
}),
],
isVisible: nil,
onActivate: { context.settings.ensureZaiAPITokenLoaded() }),
]
}
}
10 changes: 8 additions & 2 deletions Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ struct KimiWebFetchStrategy: ProviderFetchStrategy {
}

#if os(macOS)
if context.settings?.kimi?.cookieSource != .off {
if KimiBrowserImportPolicy.allowsImport(context) {
if KimiCookieImporter.desktopAuthToken() != nil {
return true
}
Expand Down Expand Up @@ -264,7 +264,7 @@ struct KimiWebFetchStrategy: ProviderFetchStrategy {

// Try browser cookie import when auto mode is enabled
#if os(macOS)
if context.settings?.kimi?.cookieSource != .off {
if KimiBrowserImportPolicy.allowsImport(context) {
if let token = KimiCookieImporter.desktopAuthToken() {
return token
}
Expand All @@ -290,3 +290,9 @@ struct KimiWebFetchStrategy: ProviderFetchStrategy {
ProviderTokenResolver.kimiAuthToken(environment: environment)
}
}

enum KimiBrowserImportPolicy {
static func allowsImport(_ context: ProviderFetchContext) -> Bool {
context.settings?.kimi?.cookieSource != .off
}
}
15 changes: 1 addition & 14 deletions Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -307,20 +307,7 @@ public enum ProviderTokenResolver {
public static func kimiAuthResolution(
environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
{
if let resolution = self.resolveEnv(KimiSettingsReader.authToken(environment: environment)) {
return resolution
}
#if os(macOS)
do {
let session = try KimiCookieImporter.importSession()
if let token = session.authToken {
return ProviderTokenResolution(token: token, source: .environment)
}
} catch {
// No browser cookies found, continue to fallback
}
#endif
return nil
self.resolveEnv(KimiSettingsReader.authToken(environment: environment))
}

public static func kimiAPIResolution(
Expand Down
50 changes: 50 additions & 0 deletions Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,53 @@ public enum ZaiProviderDescriptor {
#endif
}
}

struct ZaiAPIFetchStrategy: ProviderFetchStrategy {
let id = "zai.api"
let kind: ProviderFetchKind = .apiToken
private let transport: any ProviderHTTPTransport
private let homeDirectory: URL

init(
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser)
{
self.transport = transport
self.homeDirectory = homeDirectory
}

func isAvailable(_ context: ProviderFetchContext) async -> Bool {
ZaiSettingsReader.apiToken(
for: self.region(context),
environment: context.env,
homeDirectory: self.homeDirectory) != nil
}

func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
let settings = context.settings?.zai
let region = self.region(context)
guard let apiKey = ZaiSettingsReader.apiToken(
for: region,
environment: context.env,
homeDirectory: self.homeDirectory)
else {
throw ZaiSettingsError.missingToken
}
let usage = try await ZaiUsageFetcher.fetchUsageWithModelUsage(
apiKey: apiKey,
region: region,
usageScope: settings?.usageScope,
teamContext: settings?.teamContext,
environment: context.env,
transport: self.transport)
return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api")
}

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

private func region(_ context: ProviderFetchContext) -> ZaiAPIRegion {
context.settings?.zai?.apiRegion ?? .global
}
}
92 changes: 91 additions & 1 deletion Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ public struct ZaiSettingsReader: Sendable {
private static let log = CodexBarLog.logger(LogCategories.zaiSettings)

public static let apiTokenKey = "Z_AI_API_KEY"
public static let bigModelAPITokenKeys = [
"BIGMODEL_API_KEY",
"ZHIPU_API_KEY",
"ZHIPUAI_API_KEY",
"GLM_API_KEY",
]
public static let bigModelAPIKeyRelativePaths = [
".coding-relay/glm-api-key",
".config/bigmodel/api_key",
".config/zhipu/api_key",
]
public static let apiHostKey = "Z_AI_API_HOST"
public static let quotaURLKey = "Z_AI_QUOTA_URL"
public static let bigModelOrganizationKey = "Z_AI_BIGMODEL_ORGANIZATION"
Expand All @@ -12,7 +23,31 @@ public struct ZaiSettingsReader: Sendable {
public static func apiToken(
environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
{
if let token = self.cleaned(environment[apiTokenKey]) { return token }
self.apiToken(for: self.inferredRegion(environment: environment), environment: environment)
}

public static func apiToken(
for region: ZaiAPIRegion,
environment: [String: String] = ProcessInfo.processInfo.environment,
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> String?
{
if let token = self.cleaned(environment[self.apiTokenKey]) {
return token
}
guard region == .bigmodelCN else { return nil }
for key in self.bigModelAPITokenKeys {
if let token = self.cleaned(environment[key]) {
return token
}
}
for relativePath in self.bigModelAPIKeyRelativePaths {
let url = homeDirectory.appendingPathComponent(relativePath, isDirectory: false)
guard FileManager.default.isReadableFile(atPath: url.path),
let raw = try? String(contentsOf: url, encoding: .utf8),
let token = self.cleaned(raw.split(whereSeparator: \.isNewline).first.map(String.init))
else { continue }
return token
}
return nil
}

Expand All @@ -36,6 +71,14 @@ public struct ZaiSettingsReader: Sendable {
try self.validateAPIHostEndpointOverride(environment: environment)
}

public static func validateEndpointOverrides(
region: ZaiAPIRegion,
environment: [String: String] = ProcessInfo.processInfo.environment) throws
{
try self.validateQuotaEndpointOverride(region: region, environment: environment)
try self.validateAPIHostEndpointOverride(region: region, environment: environment)
}

public static func validateQuotaEndpointOverride(
environment: [String: String] = ProcessInfo.processInfo.environment) throws
{
Expand All @@ -49,6 +92,22 @@ public struct ZaiSettingsReader: Sendable {
try self.validateAPIHostEndpointOverride(environment: environment)
}

public static func validateQuotaEndpointOverride(
region: ZaiAPIRegion,
environment: [String: String] = ProcessInfo.processInfo.environment) throws
{
try self.validateQuotaEndpointOverride(environment: environment)
if let url = self.quotaURL(environment: environment) {
try self.validateKnownHost(url, region: region, key: self.quotaURLKey)
return
}
if let raw = self.apiHost(environment: environment),
let url = ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw)
{
try self.validateKnownHost(url, region: region, key: self.apiHostKey)
}
}

public static func validateAPIHostEndpointOverride(
environment: [String: String] = ProcessInfo.processInfo.environment) throws
{
Expand All @@ -58,6 +117,34 @@ public struct ZaiSettingsReader: Sendable {
}
}

public static func validateAPIHostEndpointOverride(
region: ZaiAPIRegion,
environment: [String: String] = ProcessInfo.processInfo.environment) throws
{
try self.validateAPIHostEndpointOverride(environment: environment)
guard let raw = self.apiHost(environment: environment),
let url = ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw)
else { return }
try self.validateKnownHost(url, region: region, key: self.apiHostKey)
}

private static func inferredRegion(environment: [String: String]) -> ZaiAPIRegion {
let host = self.quotaURL(environment: environment)?.host?.lowercased()
?? self.apiHost(environment: environment)
.flatMap { ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: $0)?.host?.lowercased() }
return host == ZaiAPIRegion.bigmodelCN.quotaLimitURL.host?.lowercased() ? .bigmodelCN : .global
}

private static func validateKnownHost(_ url: URL, region: ZaiAPIRegion, key: String) throws {
let host = url.host?.lowercased()
let globalHost = ZaiAPIRegion.global.quotaLimitURL.host?.lowercased()
let chinaHost = ZaiAPIRegion.bigmodelCN.quotaLimitURL.host?.lowercased()
guard host == globalHost || host == chinaHost else { return }
guard host == region.quotaLimitURL.host?.lowercased() else {
throw ZaiSettingsError.endpointRegionMismatch(key, region)
}
}

static func cleaned(_ raw: String?) -> String? {
guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
Expand All @@ -77,13 +164,16 @@ public struct ZaiSettingsReader: Sendable {
public enum ZaiSettingsError: LocalizedError, Sendable, Equatable {
case missingToken
case invalidEndpointOverride(String)
case endpointRegionMismatch(String, ZaiAPIRegion)

public var errorDescription: String? {
switch self {
case .missingToken:
"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY."
case let .invalidEndpointOverride(key):
"z.ai endpoint override \(key) must use HTTPS or a bare host."
case let .endpointRegionMismatch(key, region):
"z.ai endpoint override \(key) does not match the selected \(region.displayName) region."
}
}
}
6 changes: 3 additions & 3 deletions Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ public struct ZaiUsageFetcher: Sendable {
guard !apiKey.isEmpty else {
throw ZaiUsageError.invalidCredentials
}
try ZaiSettingsReader.validateQuotaEndpointOverride(environment: environment)
try ZaiSettingsReader.validateQuotaEndpointOverride(region: region, environment: environment)

let resolvedScope = usageScope ?? .personal
let quotaURL = try self.requestURL(
Expand Down Expand Up @@ -717,7 +717,7 @@ extension ZaiUsageFetcher {
guard !apiKey.isEmpty else {
throw ZaiUsageError.invalidCredentials
}
try ZaiSettingsReader.validateAPIHostEndpointOverride(environment: environment)
try ZaiSettingsReader.validateAPIHostEndpointOverride(region: region, environment: environment)

let resolvedScope = usageScope ?? .personal
let resolvedTeamContext = try self.resolvedTeamContext(
Expand Down Expand Up @@ -821,7 +821,7 @@ extension ZaiUsageFetcher {
environment: [String: String] = ProcessInfo.processInfo.environment,
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ZaiUsageSnapshot
{
try ZaiSettingsReader.validateEndpointOverrides(environment: environment)
try ZaiSettingsReader.validateEndpointOverrides(region: region, environment: environment)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't fail quota refresh on optional model endpoint mismatch

When both overrides are set, Z_AI_QUOTA_URL wins for the required quota request and Z_AI_API_HOST only feeds the optional model-usage calls; this upfront validateEndpointOverrides now rejects a stale canonical API host (for example Z_AI_QUOTA_URL pointing at BigModel CN while Z_AI_API_HOST=api.z.ai and the selected region is CN) before fetchUsage can run. That turns an optional chart-endpoint mismatch, which the later fetchModelUsage blocks already catch as non-fatal, into a full usage refresh failure; validate only the quota endpoint here or defer API-host region checks to the optional model-usage path.

Useful? React with 👍 / 👎.

let snapshot = try await Self.fetchUsage(
apiKey: apiKey,
region: region,
Expand Down
14 changes: 14 additions & 0 deletions Tests/CodexBarTests/KimiProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,20 @@ struct KimiSettingsReaderTests {
}

struct KimiAPIFetchStrategyTests {
@Test
func `cookie source off disables every browser import path`() {
let offContext = makeKimiFetchContext(
sourceMode: .auto,
settings: .make(kimi: .init(cookieSource: .off, manualCookieHeader: nil)))
let autoContext = makeKimiFetchContext(
sourceMode: .auto,
settings: .make(kimi: .init(cookieSource: .auto, manualCookieHeader: nil)))

#expect(KimiBrowserImportPolicy.allowsImport(offContext) == false)
#expect(KimiBrowserImportPolicy.allowsImport(autoContext))
#expect(ProviderTokenResolver.kimiAuthResolution(environment: [:]) == nil)
}

@Test
func `cookie source off skips monthly enrichment resolution`() async throws {
let transport = ProviderHTTPTransportStub { request in
Expand Down
Loading