204 lines
8.3 KiB
Swift
204 lines
8.3 KiB
Swift
import Foundation
|
|
|
|
public enum KimiSettingsReader {
|
|
public static let apiKeyEnvironmentKeys = ["KIMI_CODE_API_KEY"]
|
|
public static let codeAPIBaseURLEnvironmentKey = "KIMI_CODE_BASE_URL"
|
|
public static let codeHomeEnvironmentKey = "KIMI_CODE_HOME"
|
|
public static let codeOAuthHostEnvironmentKeys = ["KIMI_CODE_OAUTH_HOST", "KIMI_OAUTH_HOST"]
|
|
public static let defaultCodeAPIBaseURL = URL(string: "https://api.kimi.com")!
|
|
private static let codePlatform = "kimi_code_cli"
|
|
|
|
public static func authToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
|
|
let raw = environment["KIMI_AUTH_TOKEN"] ?? environment["kimi_auth_token"]
|
|
return self.cleaned(raw)
|
|
}
|
|
|
|
public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
|
|
for key in self.apiKeyEnvironmentKeys {
|
|
if let value = self.cleaned(environment[key]) {
|
|
return value
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public static func codeAPIBaseURL(
|
|
environment: [String: String] = ProcessInfo.processInfo.environment) throws -> URL
|
|
{
|
|
guard let raw = self.cleaned(environment[self.codeAPIBaseURLEnvironmentKey]) else {
|
|
return self.defaultCodeAPIBaseURL
|
|
}
|
|
|
|
guard URL(string: raw)?.scheme != nil,
|
|
let url = ProviderEndpointOverrideValidator().validatedURL(raw)
|
|
else {
|
|
throw KimiAPIError.invalidRequest("Kimi Code API base URL must use HTTPS without user info")
|
|
}
|
|
return url
|
|
}
|
|
|
|
public static func kimiCodeAccessToken(
|
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
now: Date = Date()) -> String?
|
|
{
|
|
guard !self.hasCodeEndpointOverride(environment: environment),
|
|
let credential = self.kimiCodeCredential(environment: environment)
|
|
else {
|
|
return nil
|
|
}
|
|
let token = credential.accessToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !token.isEmpty, self.isKimiCodeCredentialFresh(credential, now: now) else { return nil }
|
|
return token
|
|
}
|
|
|
|
public static func hasKimiCodeCredential(
|
|
environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool
|
|
{
|
|
guard !self.hasCodeEndpointOverride(environment: environment),
|
|
let credential = self.kimiCodeCredential(environment: environment)
|
|
else {
|
|
return false
|
|
}
|
|
return self.cleaned(credential.accessToken) != nil || self.cleaned(credential.refreshToken) != nil
|
|
}
|
|
|
|
static func kimiCodeIdentityHeaders(environment: [String: String]) -> [String: String] {
|
|
let deviceID = self.kimiCodeDeviceID(environment: environment)
|
|
let version = self.asciiHeaderValue(
|
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "development")
|
|
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
|
let osVersionString = "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
|
return [
|
|
"User-Agent": "CodexBar/\(version)",
|
|
"X-Msh-Platform": self.codePlatform,
|
|
"X-Msh-Version": version,
|
|
"X-Msh-Device-Name": self.asciiHeaderValue(ProcessInfo.processInfo.hostName),
|
|
"X-Msh-Device-Model": self.asciiHeaderValue(
|
|
"\(self.operatingSystemName) \(osVersionString) \(self.architectureName)"),
|
|
"X-Msh-Os-Version": self.asciiHeaderValue(osVersionString),
|
|
"X-Msh-Device-Id": deviceID,
|
|
]
|
|
}
|
|
|
|
private static func hasCodeEndpointOverride(environment: [String: String]) -> Bool {
|
|
if self.cleaned(environment[self.codeAPIBaseURLEnvironmentKey]) != nil { return true }
|
|
return self.codeOAuthHostEnvironmentKeys.contains { self.cleaned(environment[$0]) != nil }
|
|
}
|
|
|
|
private static func kimiCodeCredential(environment: [String: String]) -> KimiCodeOAuthCredential? {
|
|
let url = self.kimiCodeHomeURL(environment: environment)
|
|
.appendingPathComponent("credentials", isDirectory: true)
|
|
.appendingPathComponent("kimi-code.json")
|
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
|
return try? JSONDecoder().decode(KimiCodeOAuthCredential.self, from: data)
|
|
}
|
|
|
|
private static func isKimiCodeCredentialFresh(_ credential: KimiCodeOAuthCredential, now: Date) -> Bool {
|
|
guard let expiresAt = credential.expiresAt, expiresAt.isFinite else { return false }
|
|
return expiresAt > now.addingTimeInterval(60).timeIntervalSince1970
|
|
}
|
|
|
|
private static func kimiCodeDeviceID(environment: [String: String]) -> String {
|
|
let home = self.kimiCodeHomeURL(environment: environment)
|
|
let url = home
|
|
.appendingPathComponent("device_id", isDirectory: false)
|
|
if let existing = self.cleaned(try? String(contentsOf: url, encoding: .utf8)) {
|
|
return existing
|
|
}
|
|
|
|
let deviceID = UUID().uuidString.lowercased()
|
|
do {
|
|
try FileManager.default.createDirectory(
|
|
at: home,
|
|
withIntermediateDirectories: true,
|
|
attributes: [.posixPermissions: 0o700])
|
|
try deviceID.write(to: url, atomically: true, encoding: .utf8)
|
|
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
|
} catch {
|
|
// The official client treats persistence as best-effort; this request can use the in-memory ID.
|
|
}
|
|
return deviceID
|
|
}
|
|
|
|
private static func kimiCodeHomeURL(environment: [String: String]) -> URL {
|
|
if let override = self.cleaned(environment[self.codeHomeEnvironmentKey]) {
|
|
return URL(fileURLWithPath: override, isDirectory: true)
|
|
}
|
|
return FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent(".kimi-code", isDirectory: true)
|
|
}
|
|
|
|
private static func asciiHeaderValue(_ raw: String, fallback: String = "unknown") -> String {
|
|
var ascii = ""
|
|
for scalar in raw.unicodeScalars where (0x20...0x7E).contains(scalar.value) {
|
|
ascii.unicodeScalars.append(scalar)
|
|
}
|
|
let value = ascii.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return value.isEmpty ? fallback : value
|
|
}
|
|
|
|
private static var operatingSystemName: String {
|
|
#if os(macOS)
|
|
"macOS"
|
|
#elseif os(Linux)
|
|
"Linux"
|
|
#else
|
|
"unknown"
|
|
#endif
|
|
}
|
|
|
|
private static var architectureName: String {
|
|
#if arch(arm64)
|
|
"arm64"
|
|
#elseif arch(x86_64)
|
|
"x86_64"
|
|
#else
|
|
"unknown"
|
|
#endif
|
|
}
|
|
|
|
private 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())
|
|
}
|
|
|
|
value = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return value.isEmpty ? nil : value
|
|
}
|
|
}
|
|
|
|
private struct KimiCodeOAuthCredential: Decodable {
|
|
let accessToken: String
|
|
let refreshToken: String
|
|
let expiresAt: TimeInterval?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case access = "access_token"
|
|
case refresh = "refresh_token"
|
|
case expiry = "expires_at"
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.accessToken = (try? container.decode(String.self, forKey: .access)) ?? ""
|
|
self.refreshToken = (try? container.decode(String.self, forKey: .refresh)) ?? ""
|
|
self.expiresAt = Self.timeIntervalValue(in: container, forKey: .expiry)
|
|
}
|
|
|
|
private static func timeIntervalValue(
|
|
in container: KeyedDecodingContainer<CodingKeys>,
|
|
forKey key: CodingKeys) -> TimeInterval?
|
|
{
|
|
if let value = try? container.decode(Double.self, forKey: key) { return value }
|
|
if let value = try? container.decode(Int64.self, forKey: key) { return TimeInterval(value) }
|
|
if let value = try? container.decode(String.self, forKey: key) { return TimeInterval(value) }
|
|
return nil
|
|
}
|
|
}
|