mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-30 09:16:07 -04:00
agent: support subscription auth via Claude Code
This commit is contained in:
@@ -36,8 +36,8 @@
|
||||
.hash = "sqlite3-3.53.2-DMxLWuAOAAA_Px0arJOIOaP4AKEu5prbsQgPMA35W1zz",
|
||||
},
|
||||
.zenai = .{
|
||||
.url = "git+https://github.com/lightpanda-io/zenai.git#00c793fdd2797ae5fc9700c189d8617f167ae0dc",
|
||||
.hash = "zenai-0.0.0-iOY_VGOnBQDaWE_Gn8cwhh0XXE8RZ8ESE7QtcITF-P73",
|
||||
.url = "git+https://github.com/lightpanda-io/zenai.git#fce390119e2a4bb5d33f55579c6f6a79dfb245f6",
|
||||
.hash = "zenai-0.0.0-iOY_VHzrBQBkTrBexUzTVhDNWGLNA7Vh2eoPuMnlUVZS",
|
||||
},
|
||||
.isocline = .{
|
||||
.url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47",
|
||||
|
||||
@@ -38,6 +38,8 @@ const Terminal = @import("Terminal.zig");
|
||||
const ansi = @import("ansi.zig");
|
||||
const SlashCommand = @import("SlashCommand.zig");
|
||||
const settings = @import("settings.zig");
|
||||
const auth = @import("auth/auth.zig");
|
||||
const models_dev = @import("auth/models_dev.zig");
|
||||
const picker = @import("picker.zig");
|
||||
const save = @import("save.zig");
|
||||
const welcome = @import("welcome.zig");
|
||||
@@ -136,6 +138,12 @@ model_credentials: ?Credentials,
|
||||
/// Allocated credentials key (Vertex gcloud token) — other keys are unowned
|
||||
/// env pointers. The AI client references it: free only after client deinit.
|
||||
owned_key: ?[:0]const u8,
|
||||
/// Active subscription (bearer) auth. Owns `model_credentials.key`, so it must
|
||||
/// outlive the AI client; refreshed between turns by `refreshAuthIfNeeded`.
|
||||
auth_session: ?auth.Session,
|
||||
/// App data dir (owned by `App`, which outlives the agent), used to cache the
|
||||
/// models.dev catalog. Null when no data dir is available.
|
||||
app_dir: ?[]const u8,
|
||||
/// True when the no-LLM state is a persisted preference (remembered null
|
||||
/// provider or runtime `/provider null`), so `reportSaved` writes
|
||||
/// `provider = null`. A transient `--no-llm` run leaves it false so saving
|
||||
@@ -262,9 +270,10 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
|
||||
const banner_before = will_repl and (!resolve or settings.hasDetectableKey(opts, remembered));
|
||||
if (banner_before) welcome.print(resolve);
|
||||
|
||||
const resolved: ?settings.ResolvedProvider = if (resolve) try settings.resolveCredentials(allocator, opts, remembered, will_repl) else null;
|
||||
var resolved: ?settings.ResolvedProvider = if (resolve) try settings.resolveCredentials(allocator, opts, remembered, will_repl) else null;
|
||||
// Before the ai_client errdefer, so on unwind the client goes first.
|
||||
errdefer if (resolved) |r| if (r.key_owned) allocator.free(r.credentials.key);
|
||||
errdefer if (resolved) |*r| if (r.session) |*s| s.deinit();
|
||||
|
||||
if (will_repl and !banner_before and resolved != null) welcome.print(resolve);
|
||||
const llm: ?Credentials = if (resolved) |r| r.credentials else null;
|
||||
@@ -321,6 +330,8 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
|
||||
.ai_client = null,
|
||||
.model_credentials = llm,
|
||||
.owned_key = if (resolved) |r| (if (r.key_owned) r.credentials.key else null) else null,
|
||||
.auth_session = if (resolved) |r| r.session else null,
|
||||
.app_dir = app.app_dir_path,
|
||||
.no_llm_persisted = remembered_no_llm,
|
||||
.model_base_url = opts.base_url,
|
||||
.model_completions = null,
|
||||
@@ -353,7 +364,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
|
||||
|
||||
try self.startSession();
|
||||
|
||||
self.ai_client = if (llm) |l| try zenai.provider.Client.init(allocator, lp.io, l, .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(l.provider), .environ = lp.environ() }) else null;
|
||||
self.ai_client = if (llm) |l| try zenai.provider.Client.init(lp.io, allocator, l, .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(l.provider), .environ = lp.environ() }) else null;
|
||||
errdefer if (self.ai_client) |c| c.deinit(allocator);
|
||||
if (self.ai_client) |c| c.setInterrupt(&self.http_interrupt);
|
||||
|
||||
@@ -382,6 +393,7 @@ pub fn deinit(self: *Agent) void {
|
||||
self.browser.deinit();
|
||||
self.notification.deinit();
|
||||
if (self.ai_client) |ai_client| ai_client.deinit(self.allocator);
|
||||
if (self.auth_session) |*s| s.deinit();
|
||||
if (self.owned_key) |k| self.allocator.free(k);
|
||||
self.allocator.free(self.model);
|
||||
for (self.available_providers) |p| self.allocator.free(p);
|
||||
@@ -905,9 +917,11 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void {
|
||||
self.terminal.printError("unknown provider: {s} (or 'null' to disable the LLM)", .{trimmed});
|
||||
return;
|
||||
};
|
||||
// Re-selecting vertex falls through — that's the token-refresh path.
|
||||
// Re-selecting vertex (token refresh) or a subscription provider (re-import
|
||||
// after logging into its CLI) falls through instead of no-op'ing.
|
||||
const vertex_project = provider == .vertex and settings.vertexProjectMode();
|
||||
if (self.model_credentials) |current| if (provider == current.provider and !vertex_project) {
|
||||
const subscription = auth.descriptorFor(provider) != null;
|
||||
if (self.model_credentials) |current| if (provider == current.provider and !vertex_project and !subscription) {
|
||||
self.terminal.printInfo("provider: {s}", .{@tagName(provider)});
|
||||
return;
|
||||
};
|
||||
@@ -916,17 +930,33 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void {
|
||||
self.terminal.printError("could not obtain a Vertex access token: {s} (details above)", .{@errorName(err)});
|
||||
return;
|
||||
};
|
||||
self.setProvider(.{ .provider = .vertex, .key = token }, token) catch |err| {
|
||||
self.setProvider(.{ .provider = .vertex, .key = token }, token, null) catch |err| {
|
||||
self.allocator.free(token);
|
||||
self.terminal.printError("failed to set provider: {s}", .{@errorName(err)});
|
||||
};
|
||||
return;
|
||||
}
|
||||
// Subscription takes priority; on no importable credential, fall through to
|
||||
// the API-key path.
|
||||
if (subscription) {
|
||||
if (auth.sessionFor(self.allocator, provider) catch null) |session| {
|
||||
var owned = session;
|
||||
self.setProvider(.{ .provider = provider, .key = owned.tokens.access_token, .auth = .bearer }, null, owned) catch |err| {
|
||||
owned.deinit();
|
||||
self.terminal.printError("failed to set provider: {s}", .{@errorName(err)});
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
const key = zenai.provider.envApiKey(lp.environ(), provider) orelse {
|
||||
if (provider == .vertex) {
|
||||
self.terminal.printError("vertex needs VERTEX_API_KEY (express mode) or GOOGLE_CLOUD_PROJECT (project mode, token via gcloud)", .{});
|
||||
return;
|
||||
}
|
||||
if (subscription) {
|
||||
self.terminal.printError("no API key or subscription for {s}; set {s} or log into Claude Code", .{ @tagName(provider), zenai.provider.envVarName(provider) });
|
||||
return;
|
||||
}
|
||||
self.terminal.printError("no API key for {s}; set {s}", .{ @tagName(provider), zenai.provider.envVarName(provider) });
|
||||
return;
|
||||
};
|
||||
@@ -939,7 +969,7 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void {
|
||||
self.terminal.printError("no llama.cpp server with a loaded model at {s}", .{self.model_base_url orelse zenai.provider.llama_cpp_default_base_url});
|
||||
return;
|
||||
}
|
||||
self.setProvider(.{ .provider = provider, .key = key }, null) catch |err| {
|
||||
self.setProvider(.{ .provider = provider, .key = key }, null, null) catch |err| {
|
||||
self.terminal.printError("failed to set provider: {s}", .{@errorName(err)});
|
||||
};
|
||||
}
|
||||
@@ -966,9 +996,11 @@ fn hfBillTo(provider: Config.AiProvider) ?[]const u8 {
|
||||
}
|
||||
|
||||
/// `owned_key` transfers ownership of an allocated `credentials.key` (Vertex
|
||||
/// gcloud token) on success; on error the caller still owns it.
|
||||
fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8) !void {
|
||||
const new_client = try zenai.provider.Client.init(self.allocator, lp.io, credentials, .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credentials.provider), .environ = lp.environ() });
|
||||
/// gcloud token) on success; on error the caller still owns it. `session`
|
||||
/// likewise transfers a subscription session that owns `credentials.key`; the
|
||||
/// previous session is freed only after the old client is gone.
|
||||
fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8, session: ?auth.Session) !void {
|
||||
const new_client = try zenai.provider.Client.init(lp.io, self.allocator, credentials, .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credentials.provider), .environ = lp.environ() });
|
||||
errdefer new_client.deinit(self.allocator);
|
||||
|
||||
// A same-provider re-select (vertex token refresh) must not reset the model.
|
||||
@@ -976,7 +1008,9 @@ fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8)
|
||||
const new_model = try self.allocator.dupe(u8, if (same_provider) self.model else zenai.provider.defaultModel(credentials.provider));
|
||||
if (self.ai_client) |client| client.deinit(self.allocator);
|
||||
if (self.owned_key) |k| self.allocator.free(k);
|
||||
if (self.auth_session) |*s| s.deinit();
|
||||
self.owned_key = owned_key;
|
||||
self.auth_session = session;
|
||||
new_client.setInterrupt(&self.http_interrupt);
|
||||
self.ai_client = new_client;
|
||||
self.model_credentials = credentials;
|
||||
@@ -984,13 +1018,38 @@ fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8)
|
||||
self.model_completions = null;
|
||||
self.allocator.free(self.model);
|
||||
self.model = new_model;
|
||||
self.terminal.printInfo("provider: {s}", .{@tagName(credentials.provider)});
|
||||
if (credentials.auth == .bearer) {
|
||||
const label = if (auth.descriptorFor(credentials.provider)) |d| d.label else "subscription";
|
||||
self.terminal.printInfo("provider: {s} ({s})", .{ @tagName(credentials.provider), label });
|
||||
} else {
|
||||
self.terminal.printInfo("provider: {s}", .{@tagName(credentials.provider)});
|
||||
}
|
||||
if (zenai.provider.defaultEffort(credentials.provider)) |e| if (e != self.effort) {
|
||||
self.effort = e;
|
||||
self.terminal.printInfo("effort: {s} ({s} default)", .{ @tagName(e), @tagName(credentials.provider) });
|
||||
};
|
||||
self.reportSaved("model", self.model);
|
||||
_ = completionModels(self, self.allocator);
|
||||
// Priming warms the completion cache; skip it for bearer, whose catalog is a
|
||||
// multi-MB models.dev download best deferred to first `/model` use.
|
||||
if (credentials.auth != .bearer) _ = completionModels(self, self.allocator);
|
||||
}
|
||||
|
||||
/// Keep a subscription (bearer) token current before a model request: when the
|
||||
/// active session's token is near expiry, re-import from the source and repoint
|
||||
/// the client and credentials at the fresh token. A no-op for API-key auth.
|
||||
/// Runs between turns on the single agent thread, never during a request, so the
|
||||
/// old buffer (held one cycle by `auth.Session`) is never dereferenced freed.
|
||||
fn refreshAuthIfNeeded(self: *Agent) void {
|
||||
if (self.auth_session) |*session| {
|
||||
const new_token = session.ensureFresh() catch |err| {
|
||||
self.terminal.printError("could not refresh the Claude subscription token: {s}", .{@errorName(err)});
|
||||
return;
|
||||
};
|
||||
if (new_token) |tok| {
|
||||
if (self.ai_client) |client| client.setApiKey(tok);
|
||||
if (self.model_credentials) |*c| c.key = tok;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PathAndMode = struct { path: []const u8, mode: save.Mode };
|
||||
@@ -1157,6 +1216,7 @@ fn saveOneShot(self: *Agent) void {
|
||||
/// interactive `/save` and one-shot `--save`.
|
||||
fn synthesizeSaveTo(self: *Agent, arena: std.mem.Allocator, path: []const u8, mode: save.Mode, prompt: ?[]const u8) void {
|
||||
const provider_client = self.ai_client.?;
|
||||
self.refreshAuthIfNeeded();
|
||||
|
||||
// Only update feeds the saved script back to the model; append stays
|
||||
// blind — the script is synthesized from this session alone and written
|
||||
@@ -1599,6 +1659,7 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 {
|
||||
}
|
||||
|
||||
const provider_client = self.ai_client orelse return error.NoAiClient;
|
||||
self.refreshAuthIfNeeded();
|
||||
|
||||
self.terminal.spinner.start();
|
||||
var result = provider_client.runTools(
|
||||
@@ -1839,13 +1900,24 @@ pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void {
|
||||
});
|
||||
return error.ConflictingFlags;
|
||||
}
|
||||
const resolved = (try settings.resolveCredentials(allocator, opts, null, false)) orelse return error.MissingProvider;
|
||||
var resolved = (try settings.resolveCredentials(allocator, opts, null, false)) orelse return error.MissingProvider;
|
||||
const llm = resolved.credentials;
|
||||
defer if (resolved.key_owned) allocator.free(llm.key);
|
||||
defer if (resolved.session) |*s| s.deinit();
|
||||
|
||||
var arena: std.heap.ArenaAllocator = .init(allocator);
|
||||
defer arena.deinit();
|
||||
const ids = zenai.provider.listChatModelIds(allocator, lp.io, arena.allocator(), llm.provider, llm.key, opts.base_url, lp.environ()) catch |err| {
|
||||
// A subscription (bearer) token can't list models via the provider; use the
|
||||
// free models.dev catalog (uncached here — this is a one-shot CLI command).
|
||||
if (llm.auth == .bearer) {
|
||||
const sub_ids = models_dev.modelIds(arena.allocator(), @tagName(llm.provider), null);
|
||||
var stdout_sub = std.Io.File.stdout().writerStreaming(lp.io, &.{});
|
||||
const ws = &stdout_sub.interface;
|
||||
for (sub_ids) |id| try ws.print("{s}\n", .{id});
|
||||
try ws.flush();
|
||||
return;
|
||||
}
|
||||
const ids = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), llm.provider, llm.key, .{ .base_url = opts.base_url, .environ = lp.environ() }) catch |err| {
|
||||
if (llm.provider == .vertex and !settings.vertexProjectMode()) {
|
||||
std.debug.print("Vertex express mode cannot list models (the endpoint requires OAuth); set GOOGLE_CLOUD_PROJECT for project mode.\n", .{});
|
||||
}
|
||||
@@ -1901,15 +1973,21 @@ fn completionModels(context: *anyopaque, _: std.mem.Allocator) []const []const u
|
||||
if (self.model_completions) |c| if (c.provider == llm.provider) return c.ids;
|
||||
|
||||
_ = self.model_completion_arena.reset(.retain_capacity);
|
||||
const ids = zenai.provider.listChatModelIds(
|
||||
self.allocator,
|
||||
lp.io,
|
||||
self.model_completion_arena.allocator(),
|
||||
llm.provider,
|
||||
llm.key,
|
||||
self.model_base_url,
|
||||
lp.environ(),
|
||||
) catch &.{};
|
||||
const arena = self.model_completion_arena.allocator();
|
||||
// A subscription (bearer) token can't hit the provider's `/models` endpoint
|
||||
// (it 401s), so list from the free, unauthenticated models.dev catalog
|
||||
// instead — no API key needed.
|
||||
const ids = if (llm.auth == .bearer)
|
||||
models_dev.modelIds(arena, @tagName(llm.provider), self.app_dir)
|
||||
else
|
||||
zenai.provider.listChatModelIds(
|
||||
lp.io,
|
||||
self.allocator,
|
||||
arena,
|
||||
llm.provider,
|
||||
llm.key,
|
||||
.{ .base_url = self.model_base_url, .environ = lp.environ() },
|
||||
) catch &.{};
|
||||
self.model_completions = .{ .provider = llm.provider, .ids = ids };
|
||||
return ids;
|
||||
}
|
||||
|
||||
64
src/agent/auth/anthropic.zig
Normal file
64
src/agent/auth/anthropic.zig
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
//! Anthropic subscription-auth descriptor plus the Claude Code credential
|
||||
//! importer. v1 reuses the OAuth token Claude Code already maintains at
|
||||
//! `$HOME/.claude/.credentials.json`; the login/refresh-endpoint fields on the
|
||||
//! descriptor are the seam for the deferred own-OAuth flow and are unused today.
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
const auth = @import("auth.zig");
|
||||
|
||||
pub const descriptor: auth.Descriptor = .{
|
||||
.provider = .anthropic,
|
||||
.id = "anthropic",
|
||||
.label = "Claude subscription",
|
||||
.authorize_url = "https://claude.ai/oauth/authorize",
|
||||
.token_url = "https://console.anthropic.com/v1/oauth/token",
|
||||
.client_id = "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
||||
.redirect_uri = "https://console.anthropic.com/oauth/code/callback",
|
||||
.scope = "org:create_api_key user:profile user:inference",
|
||||
.importFn = importClaudeCode,
|
||||
};
|
||||
|
||||
/// Read Claude Code's OAuth credentials from `$HOME/.claude/.credentials.json`.
|
||||
/// Returns null when the file is absent, unreadable, or malformed (the caller
|
||||
/// falls back to API-key auth). Tokens are duped into `allocator`.
|
||||
pub fn importClaudeCode(allocator: std.mem.Allocator) !?auth.TokenSet {
|
||||
const home = std.c.getenv("HOME") orelse return null;
|
||||
const path = try std.fs.path.join(allocator, &.{ std.mem.span(home), ".claude", ".credentials.json" });
|
||||
defer allocator.free(path);
|
||||
|
||||
const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, allocator, .limited(64 * 1024)) catch return null;
|
||||
defer allocator.free(data);
|
||||
|
||||
const Shape = struct {
|
||||
claudeAiOauth: ?struct {
|
||||
accessToken: []const u8,
|
||||
refreshToken: []const u8 = "",
|
||||
expiresAt: i64 = 0,
|
||||
} = null,
|
||||
};
|
||||
const parsed = std.json.parseFromSlice(Shape, allocator, data, .{ .ignore_unknown_fields = true }) catch return null;
|
||||
defer parsed.deinit();
|
||||
|
||||
const oauth = parsed.value.claudeAiOauth orelse return null;
|
||||
if (oauth.accessToken.len == 0) return null;
|
||||
return try auth.TokenSet.dup(allocator, oauth.accessToken, oauth.refreshToken, oauth.expiresAt);
|
||||
}
|
||||
176
src/agent/auth/auth.zig
Normal file
176
src/agent/auth/auth.zig
Normal file
@@ -0,0 +1,176 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
//! Provider-agnostic subscription (OAuth bearer) auth for the agent. v1 imports
|
||||
//! the token a subscription CLI already maintains on disk — Claude Code for
|
||||
//! `.anthropic` — and re-reads it on expiry, so the agent never calls the OAuth
|
||||
//! token endpoint and cannot disturb that CLI's own login. The `Descriptor`'s
|
||||
//! login-endpoint fields are the seam for a future own-OAuth login flow.
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
const Config = lp.Config;
|
||||
const anthropic = @import("anthropic.zig");
|
||||
|
||||
/// Wall-clock ms since the Unix epoch.
|
||||
pub fn nowMs() i64 {
|
||||
return std.Io.Clock.now(.real, lp.io).toMilliseconds();
|
||||
}
|
||||
|
||||
/// An OAuth credential set. `refresh_token`/`expires_at_ms` back expiry handling
|
||||
/// (and the deferred refresh grant).
|
||||
pub const TokenSet = struct {
|
||||
access_token: [:0]const u8,
|
||||
refresh_token: []const u8,
|
||||
expires_at_ms: i64,
|
||||
|
||||
pub fn dup(allocator: std.mem.Allocator, access: []const u8, refresh: []const u8, expires_at_ms: i64) !TokenSet {
|
||||
const a = try allocator.dupeZ(u8, access);
|
||||
errdefer allocator.free(a);
|
||||
const r = try allocator.dupe(u8, refresh);
|
||||
return .{ .access_token = a, .refresh_token = r, .expires_at_ms = expires_at_ms };
|
||||
}
|
||||
|
||||
pub fn deinit(self: TokenSet, allocator: std.mem.Allocator) void {
|
||||
allocator.free(self.access_token);
|
||||
allocator.free(self.refresh_token);
|
||||
}
|
||||
};
|
||||
|
||||
/// Static per-provider OAuth configuration. Adding a provider is a data addition
|
||||
/// here plus an `importFn`. The login-endpoint fields are the seam for the
|
||||
/// deferred own-OAuth login flow and are unused in v1.
|
||||
pub const Descriptor = struct {
|
||||
provider: Config.AiProvider,
|
||||
id: []const u8,
|
||||
/// Human label for the credential, e.g. "Claude subscription".
|
||||
label: []const u8,
|
||||
authorize_url: []const u8,
|
||||
token_url: []const u8,
|
||||
client_id: []const u8,
|
||||
redirect_uri: []const u8,
|
||||
scope: []const u8,
|
||||
/// Import a subscription token another CLI already maintains on disk.
|
||||
importFn: ?*const fn (std.mem.Allocator) anyerror!?TokenSet = null,
|
||||
};
|
||||
|
||||
pub const registry = [_]*const Descriptor{&anthropic.descriptor};
|
||||
|
||||
pub fn descriptorFor(provider: Config.AiProvider) ?*const Descriptor {
|
||||
for (registry) |d| if (d.provider == provider) return d;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Proactive-refresh margin: re-check the source once the token is within this
|
||||
/// window of expiry, so a turn never starts on a token about to lapse.
|
||||
const refresh_skew_ms: i64 = 5 * std.time.ms_per_min;
|
||||
|
||||
/// A live subscription credential for one provider. Owns its `TokenSet`; the AI
|
||||
/// client borrows `tokens.access_token`, so the session must outlive the client.
|
||||
pub const Session = struct {
|
||||
allocator: std.mem.Allocator,
|
||||
descriptor: *const Descriptor,
|
||||
tokens: TokenSet,
|
||||
/// The immediately-prior token, kept alive one refresh cycle so a client
|
||||
/// still pointing at it (until `setApiKey`) never dereferences freed memory.
|
||||
previous: ?TokenSet = null,
|
||||
|
||||
/// When the access token is within `refresh_skew_ms` of expiry, re-import
|
||||
/// from the source and, if the source has a newer token, adopt it and return
|
||||
/// the new access token (owned by the session). Returns null when nothing
|
||||
/// changed. The caller must repoint its client with the returned token
|
||||
/// before its next request; the old buffer stays valid until the following
|
||||
/// `ensureFresh`/`deinit`. Errors `SubscriptionTokenExpired` when the token
|
||||
/// has lapsed and the source has no fresher one.
|
||||
pub fn ensureFresh(self: *Session) !?[:0]const u8 {
|
||||
const now = nowMs();
|
||||
if (self.tokens.expires_at_ms - now > refresh_skew_ms) return null;
|
||||
|
||||
const importFn = self.descriptor.importFn orelse return self.staleResult(now);
|
||||
const fresh = (try importFn(self.allocator)) orelse return self.staleResult(now);
|
||||
// The source (e.g. Claude Code) hasn't refreshed yet.
|
||||
if (fresh.expires_at_ms <= self.tokens.expires_at_ms) {
|
||||
fresh.deinit(self.allocator);
|
||||
return self.staleResult(now);
|
||||
}
|
||||
|
||||
if (self.previous) |p| p.deinit(self.allocator);
|
||||
self.previous = self.tokens;
|
||||
self.tokens = fresh;
|
||||
return self.tokens.access_token;
|
||||
}
|
||||
|
||||
fn staleResult(self: *Session, now: i64) error{SubscriptionTokenExpired}!?[:0]const u8 {
|
||||
return if (self.tokens.expires_at_ms > now) null else error.SubscriptionTokenExpired;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Session) void {
|
||||
self.tokens.deinit(self.allocator);
|
||||
if (self.previous) |p| p.deinit(self.allocator);
|
||||
self.previous = null;
|
||||
}
|
||||
};
|
||||
|
||||
/// Build a bearer session for `provider` from an available subscription
|
||||
/// credential, or null when none is importable.
|
||||
pub fn sessionFor(allocator: std.mem.Allocator, provider: Config.AiProvider) !?Session {
|
||||
const desc = descriptorFor(provider) orelse return null;
|
||||
const importFn = desc.importFn orelse return null;
|
||||
const tokens = (try importFn(allocator)) orelse return null;
|
||||
return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens };
|
||||
}
|
||||
|
||||
/// Process-lifetime memo for `subscriptionAvailable`: the credential file is
|
||||
/// probed several times across a single startup resolution, and it doesn't
|
||||
/// change under us there (runtime `/provider` re-import goes through `sessionFor`,
|
||||
/// which always reads fresh).
|
||||
var availability_cache: std.enums.EnumArray(Config.AiProvider, ?bool) = .initFill(null);
|
||||
|
||||
/// Is a usable (present, not hard-expired) subscription token importable for
|
||||
/// `provider`? Lets the picker offer a subscription without its API-key env var,
|
||||
/// and not offer it when no credential exists.
|
||||
pub fn subscriptionAvailable(provider: Config.AiProvider) bool {
|
||||
if (availability_cache.get(provider)) |cached| return cached;
|
||||
const result = probeSubscription(provider);
|
||||
availability_cache.set(provider, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
fn probeSubscription(provider: Config.AiProvider) bool {
|
||||
const desc = descriptorFor(provider) orelse return false;
|
||||
const importFn = desc.importFn orelse return false;
|
||||
var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator);
|
||||
defer arena.deinit();
|
||||
const tokens = (importFn(arena.allocator()) catch return false) orelse return false;
|
||||
// expires_at_ms == 0 means "unknown"; let a live request be the judge.
|
||||
return tokens.expires_at_ms == 0 or tokens.expires_at_ms > nowMs();
|
||||
}
|
||||
|
||||
test "TokenSet dup/deinit round-trips and is leak-free" {
|
||||
const a = std.testing.allocator;
|
||||
const t = try TokenSet.dup(a, "acc", "ref", 123);
|
||||
defer t.deinit(a);
|
||||
try std.testing.expectEqualStrings("acc", t.access_token);
|
||||
try std.testing.expectEqualStrings("ref", t.refresh_token);
|
||||
try std.testing.expectEqual(@as(i64, 123), t.expires_at_ms);
|
||||
}
|
||||
|
||||
test "descriptorFor resolves anthropic, null for a non-OAuth provider" {
|
||||
try std.testing.expect(descriptorFor(.anthropic) != null);
|
||||
try std.testing.expectEqual(@as(?*const Descriptor, null), descriptorFor(.openai));
|
||||
}
|
||||
125
src/agent/auth/models_dev.zig
Normal file
125
src/agent/auth/models_dev.zig
Normal file
@@ -0,0 +1,125 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
//! Model catalog from models.dev — the free, unauthenticated registry opencode
|
||||
//! uses. It lets a subscription (bearer) user list a provider's models without
|
||||
//! an API key, and stays current upstream instead of drifting like a hardcoded
|
||||
//! list. The full `api.json` is a few MB, so only each provider's id list is
|
||||
//! cached on disk (`<app_dir>/models-dev-<provider>.json`) with a TTL; the big
|
||||
//! payload is parsed transiently on refresh.
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
const auth = @import("auth.zig");
|
||||
|
||||
const api_url = "https://models.dev/api.json";
|
||||
const cache_ttl_ms: i64 = 24 * std.time.ms_per_hour;
|
||||
|
||||
/// Chat-model ids for `provider_id` from models.dev, allocated in `arena`. Reads
|
||||
/// a fresh on-disk cache when present, else fetches and refreshes it. Returns an
|
||||
/// empty slice on any failure (offline, parse error, no cache) so callers
|
||||
/// degrade to accepting a typed-in model name.
|
||||
pub fn modelIds(arena: std.mem.Allocator, provider_id: []const u8, app_dir: ?[]const u8) []const []const u8 {
|
||||
if (app_dir) |dir| {
|
||||
if (readCache(arena, dir, provider_id)) |ids| return ids;
|
||||
}
|
||||
const catalog = fetch(arena) catch return &.{};
|
||||
const ids = parseProviderModels(arena, catalog, provider_id) catch return &.{};
|
||||
if (app_dir) |dir| writeCache(dir, provider_id, ids) catch {};
|
||||
return ids;
|
||||
}
|
||||
|
||||
const Cache = struct {
|
||||
fetched_ms: i64,
|
||||
ids: []const []const u8,
|
||||
};
|
||||
|
||||
fn cachePath(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []const u8) ![]const u8 {
|
||||
const name = try std.fmt.allocPrint(arena, "models-dev-{s}.json", .{provider_id});
|
||||
return std.fs.path.join(arena, &.{ app_dir, name });
|
||||
}
|
||||
|
||||
fn readCache(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []const u8) ?[]const []const u8 {
|
||||
const path = cachePath(arena, app_dir, provider_id) catch return null;
|
||||
const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, arena, .limited(1024 * 1024)) catch return null;
|
||||
const parsed = std.json.parseFromSliceLeaky(Cache, arena, data, .{ .ignore_unknown_fields = true }) catch return null;
|
||||
if (auth.nowMs() - parsed.fetched_ms > cache_ttl_ms) return null;
|
||||
if (parsed.ids.len == 0) return null;
|
||||
return parsed.ids;
|
||||
}
|
||||
|
||||
fn writeCache(app_dir: []const u8, provider_id: []const u8, ids: []const []const u8) !void {
|
||||
var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
const path = try cachePath(a, app_dir, provider_id);
|
||||
|
||||
var buf: std.Io.Writer.Allocating = .init(a);
|
||||
try std.json.Stringify.value(Cache{ .fetched_ms = auth.nowMs(), .ids = ids }, .{}, &buf.writer);
|
||||
try std.Io.Dir.cwd().writeFile(lp.io, .{ .sub_path = path, .data = buf.written() });
|
||||
}
|
||||
|
||||
/// Model-id keys of `catalog[provider_id].models`. `ignore_unknown_fields` skips
|
||||
/// every provider and model's metadata, so only the id strings are built — no
|
||||
/// Value tree for the multi-MB payload. The parse runs in a scoped arena; the
|
||||
/// ids are duped into `arena`.
|
||||
fn parseProviderModels(arena: std.mem.Allocator, catalog: []const u8, provider_id: []const u8) ![]const []const u8 {
|
||||
var parse_arena: std.heap.ArenaAllocator = .init(arena);
|
||||
defer parse_arena.deinit();
|
||||
|
||||
const Empty = struct {};
|
||||
const Provider = struct { models: std.json.ArrayHashMap(Empty) = .{} };
|
||||
const parsed = try std.json.parseFromSliceLeaky(std.json.ArrayHashMap(Provider), parse_arena.allocator(), catalog, .{ .ignore_unknown_fields = true });
|
||||
|
||||
const provider = parsed.map.get(provider_id) orelse return error.ProviderMissing;
|
||||
const keys = provider.models.map.keys();
|
||||
const ids = try arena.alloc([]const u8, keys.len);
|
||||
for (keys, ids) |k, *dst| dst.* = try arena.dupe(u8, k);
|
||||
return ids;
|
||||
}
|
||||
|
||||
fn fetch(arena: std.mem.Allocator) ![]u8 {
|
||||
var client: std.http.Client = .{ .allocator = arena, .io = lp.io };
|
||||
defer client.deinit();
|
||||
|
||||
const uri = try std.Uri.parse(api_url);
|
||||
var req = try client.request(.GET, uri, .{ .redirect_behavior = @enumFromInt(3) });
|
||||
defer req.deinit();
|
||||
try req.sendBodiless();
|
||||
|
||||
var redirect_buffer: [8 * 1024]u8 = undefined;
|
||||
var response = try req.receiveHead(&redirect_buffer);
|
||||
if (response.head.status != .ok) return error.HttpStatus;
|
||||
|
||||
const decompress_buffer: []u8 = switch (response.head.content_encoding) {
|
||||
.identity => &.{},
|
||||
.zstd => try arena.alloc(u8, std.compress.zstd.default_window_len),
|
||||
.deflate, .gzip => try arena.alloc(u8, std.compress.flate.max_window_len),
|
||||
.compress => return error.UnsupportedCompression,
|
||||
};
|
||||
var transfer_buffer: [4096]u8 = undefined;
|
||||
var decompress: std.http.Decompress = undefined;
|
||||
const reader = response.readerDecompressing(&transfer_buffer, &decompress, decompress_buffer);
|
||||
|
||||
var out: std.Io.Writer.Allocating = .init(arena);
|
||||
_ = reader.streamRemaining(&out.writer) catch |err| switch (err) {
|
||||
error.ReadFailed => return response.bodyErr().?,
|
||||
else => |e| return e,
|
||||
};
|
||||
return out.written();
|
||||
}
|
||||
@@ -28,9 +28,10 @@ const lp = @import("lightpanda");
|
||||
const Config = lp.Config;
|
||||
const picker = @import("picker.zig");
|
||||
const string = @import("../string.zig");
|
||||
const auth = @import("auth/auth.zig");
|
||||
const Credentials = zenai.provider.Credentials;
|
||||
|
||||
pub const api_keys_hint = "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, HF_TOKEN, AI_GATEWAY_API_KEY, or MISTRAL_API_KEY (Vertex AI: VERTEX_API_KEY, or GOOGLE_CLOUD_PROJECT via gcloud)";
|
||||
pub const api_keys_hint = "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, HF_TOKEN, AI_GATEWAY_API_KEY, or MISTRAL_API_KEY (Vertex AI: VERTEX_API_KEY, or GOOGLE_CLOUD_PROJECT via gcloud; Anthropic: a Claude subscription from Claude Code)";
|
||||
|
||||
/// Determine which provider to use and read its env key. Returns null
|
||||
/// only when no `--provider` was given AND no env key exists (the caller
|
||||
@@ -41,6 +42,10 @@ pub const ResolvedProvider = struct {
|
||||
/// Key allocated (Vertex gcloud token) rather than an env pointer; the
|
||||
/// caller frees it, only after the client that references it is gone.
|
||||
key_owned: bool = false,
|
||||
/// Set for subscription (bearer) auth. Owns `credentials.key`, so it must
|
||||
/// outlive the AI client; the caller takes ownership. When set, `key_owned`
|
||||
/// stays false — the session frees the key, not the caller directly.
|
||||
session: ?auth.Session = null,
|
||||
};
|
||||
|
||||
/// Probe a keyless local provider (Ollama, llama.cpp): its env key is a
|
||||
@@ -50,7 +55,7 @@ pub fn detectLocalProvider(allocator: std.mem.Allocator, tag: Config.AiProvider,
|
||||
const key = zenai.provider.envApiKey(lp.environ(), tag) orelse return null;
|
||||
var arena: std.heap.ArenaAllocator = .init(allocator);
|
||||
defer arena.deinit();
|
||||
const ids = zenai.provider.listChatModelIds(allocator, lp.io, arena.allocator(), tag, key, base_url, lp.environ()) catch return null;
|
||||
const ids = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), tag, key, .{ .base_url = base_url, .environ = lp.environ() }) catch return null;
|
||||
if (ids.len == 0) return null;
|
||||
return .{ .provider = tag, .key = key };
|
||||
}
|
||||
@@ -97,10 +102,11 @@ pub fn gcloudAccessToken(allocator: std.mem.Allocator) ![:0]const u8 {
|
||||
/// env-detected). Skips the Ollama probe so it isn't run twice at startup; the
|
||||
/// interactive picker only fires on detected keys, which this still catches.
|
||||
pub fn hasDetectableKey(opts: Config.Agent, remembered: ?Remembered) bool {
|
||||
if (opts.provider) |p| return zenai.provider.envApiKey(lp.environ(), p) != null or (p == .vertex and vertexProjectMode());
|
||||
if (opts.provider) |p| return zenai.provider.envApiKey(lp.environ(), p) != null or (p == .vertex and vertexProjectMode()) or auth.subscriptionAvailable(p);
|
||||
if (remembered) |r| if (r.provider) |p| {
|
||||
if (zenai.provider.envApiKey(lp.environ(), p) != null) return true;
|
||||
if (p == .vertex and vertexProjectMode()) return true;
|
||||
if (auth.subscriptionAvailable(p)) return true;
|
||||
};
|
||||
var buf: [zenai.provider.default_candidates.len]Credentials = undefined;
|
||||
return availableProviders(&buf).len > 0;
|
||||
@@ -114,6 +120,9 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme
|
||||
const token = try gcloudAccessToken(allocator);
|
||||
return .{ .credentials = .{ .provider = p, .key = token }, .source = .flag, .key_owned = true };
|
||||
}
|
||||
// A subscription takes priority over an API key; null = not a
|
||||
// subscription provider (or none available), so fall through.
|
||||
if (try subscriptionResolved(allocator, p, .flag)) |resolved| return resolved;
|
||||
const key = zenai.provider.envApiKey(lp.environ(), p) orelse {
|
||||
if (p == .vertex) {
|
||||
std.debug.print(
|
||||
@@ -137,8 +146,13 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme
|
||||
if (gcloudAccessToken(allocator)) |token| {
|
||||
return .{ .credentials = .{ .provider = p, .key = token }, .source = .remembered, .key_owned = true };
|
||||
} else |_| {}
|
||||
} else if (zenai.provider.envApiKey(lp.environ(), p)) |key| {
|
||||
return .{ .credentials = .{ .provider = p, .key = key }, .source = .remembered };
|
||||
} else {
|
||||
// Subscription takes priority over an API key; both fall through to
|
||||
// detection on miss.
|
||||
if (try subscriptionResolved(allocator, p, .remembered)) |resolved| return resolved;
|
||||
if (zenai.provider.envApiKey(lp.environ(), p)) |key| {
|
||||
return .{ .credentials = .{ .provider = p, .key = key }, .source = .remembered };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -173,16 +187,41 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme
|
||||
return try finishResolved(allocator, found[idx], .picked);
|
||||
}
|
||||
|
||||
/// Swaps the placeholder key of a detected project-mode Vertex for a real
|
||||
/// gcloud token.
|
||||
/// Swaps a placeholder credential for a live token: a gcloud token for
|
||||
/// project-mode Vertex, or an imported subscription session for the Anthropic
|
||||
/// bearer placeholder.
|
||||
fn finishResolved(allocator: std.mem.Allocator, credentials: Credentials, source: @FieldType(ResolvedProvider, "source")) !ResolvedProvider {
|
||||
if (credentials.provider == .vertex and vertexProjectMode()) {
|
||||
const token = try gcloudAccessToken(allocator);
|
||||
return .{ .credentials = .{ .provider = .vertex, .key = token }, .source = source, .key_owned = true };
|
||||
}
|
||||
if (credentials.auth == .bearer and credentials.key.len == 0) {
|
||||
if (try subscriptionResolved(allocator, credentials.provider, source)) |resolved| return resolved;
|
||||
return error.MissingApiKey;
|
||||
}
|
||||
return .{ .credentials = credentials, .source = source };
|
||||
}
|
||||
|
||||
/// Import a subscription and wrap it as a resolved bearer credential. The
|
||||
/// returned `session` owns `credentials.key`; the caller must keep it alive as
|
||||
/// long as the AI client and free it with `session.deinit`. Null when no
|
||||
/// subscription is importable.
|
||||
fn subscriptionResolved(allocator: std.mem.Allocator, provider: Config.AiProvider, source: @FieldType(ResolvedProvider, "source")) !?ResolvedProvider {
|
||||
const session = (try auth.sessionFor(allocator, provider)) orelse return null;
|
||||
// Name the credential in effect — a set-but-ignored API key would otherwise
|
||||
// be a silent surprise.
|
||||
if (zenai.provider.envApiKey(lp.environ(), provider) != null) {
|
||||
std.debug.print("{s}: using your {s}; {s} is set but the subscription takes priority.\n", .{ @tagName(provider), session.descriptor.label, zenai.provider.envVarName(provider) });
|
||||
} else {
|
||||
std.debug.print("{s}: using your {s}.\n", .{ @tagName(provider), session.descriptor.label });
|
||||
}
|
||||
return .{
|
||||
.credentials = .{ .provider = provider, .key = session.tokens.access_token, .auth = .bearer },
|
||||
.source = source,
|
||||
.session = session,
|
||||
};
|
||||
}
|
||||
|
||||
pub const remembered_path = ".lp-agent.zon";
|
||||
|
||||
/// Last user-selected provider/model/effort/verbosity, persisted per-directory
|
||||
@@ -234,7 +273,20 @@ pub fn saveRemembered(remembered: Remembered) !void {
|
||||
/// Vertex project mode joins with a placeholder key — no subprocess during a
|
||||
/// scan; the gcloud token is fetched on selection (`finishResolved`).
|
||||
pub fn availableProviders(buf: []Credentials) []Credentials {
|
||||
const found = zenai.provider.detectKeys(lp.environ(), buf, zenai.provider.default_candidates);
|
||||
var found = zenai.provider.detectKeys(lp.environ(), buf, zenai.provider.default_candidates);
|
||||
// A subscription takes priority over an API key: offer it as a bearer
|
||||
// placeholder (replacing any API-key entry) that `finishResolved` swaps for a
|
||||
// live token on selection, mirroring Vertex project mode below.
|
||||
for (auth.registry) |desc| {
|
||||
if (!auth.subscriptionAvailable(desc.provider)) continue;
|
||||
const placeholder: Credentials = .{ .provider = desc.provider, .key = "", .auth = .bearer };
|
||||
if (indexOfProvider(found, desc.provider)) |i| {
|
||||
found[i] = placeholder;
|
||||
} else if (found.len < buf.len) {
|
||||
buf[found.len] = placeholder;
|
||||
found = buf[0 .. found.len + 1];
|
||||
}
|
||||
}
|
||||
if (zenai.provider.useVertex(lp.environ()) and vertexProjectMode() and found.len < buf.len) {
|
||||
buf[found.len] = .{ .provider = .vertex, .key = "" };
|
||||
return buf[0 .. found.len + 1];
|
||||
@@ -242,6 +294,11 @@ pub fn availableProviders(buf: []Credentials) []Credentials {
|
||||
return found;
|
||||
}
|
||||
|
||||
fn indexOfProvider(creds: []const Credentials, provider: Config.AiProvider) ?usize {
|
||||
for (creds, 0..) |c, i| if (c.provider == provider) return i;
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn resolveModelName(opts: Config.Agent, resolved: ?ResolvedProvider, remembered: ?Remembered) []const u8 {
|
||||
if (opts.model) |m| return m;
|
||||
if (resolved) |r| {
|
||||
@@ -300,9 +357,13 @@ pub fn reconcileModel(
|
||||
base_url: ?[:0]const u8,
|
||||
explicit: bool,
|
||||
) !ReconciledModel {
|
||||
// A subscription (bearer) token can't list models; trust the desired model
|
||||
// as-is rather than 401 against `/models`.
|
||||
if (llm.auth == .bearer) return .{ .use = try allocator.dupe(u8, desired) };
|
||||
|
||||
var arena: std.heap.ArenaAllocator = .init(allocator);
|
||||
defer arena.deinit();
|
||||
const ids: []const []const u8 = zenai.provider.listChatModelIds(allocator, lp.io, arena.allocator(), llm.provider, llm.key, base_url, lp.environ()) catch &.{};
|
||||
const ids: []const []const u8 = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), llm.provider, llm.key, .{ .base_url = base_url, .environ = lp.environ() }) catch &.{};
|
||||
if (ids.len == 0 or string.isOneOf(desired, ids)) return .{ .use = try allocator.dupe(u8, desired) };
|
||||
|
||||
if (!explicit) {
|
||||
|
||||
@@ -982,7 +982,7 @@ fn tavilySearch(
|
||||
api_key: []const u8,
|
||||
query: []const u8,
|
||||
) ![]const u8 {
|
||||
var client: tavily.Client = .init(arena, lp.io, api_key, .{});
|
||||
var client: tavily.Client = .init(lp.io, arena, api_key, .{});
|
||||
defer client.deinit();
|
||||
|
||||
var response = client.search(query, .{ .max_results = 10 }) catch |err| {
|
||||
|
||||
Reference in New Issue
Block a user