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
24 changes: 22 additions & 2 deletions src/cli/effort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ export { EffortCommand };
import * as p from "@clack/prompts";

import { Future } from "@/libs/future";
import { type Config } from "@/domain/config/config";
import { type Config, type ProviderConfig } from "@/domain/config/config";
import { loadConfig, saveConfig } from "@/infra/storage/config";
import { selectEffortForProvider } from "@/domain/llm/effort";
import { resolveProvider } from "@/domain/llm/auth-resolver";
import { fetchModels } from "@/domain/commit/models";
import { loading } from "@/infra/ui/spinner";
import { fromOptional } from "@/libs/maybe";

import color from "picocolors";

Expand All @@ -21,12 +25,28 @@ class EffortCommand {
run(): Future<Error, void> {
p.intro(color.bgCyan(color.black(" Change Effort ")));

return selectEffortForProvider(this.config.ai)
return this.selectEffort()
.chain((ai) => saveConfig({ ...this.config, ai }))
.map(() => p.outro(color.green("Effort updated successfully!")))
.mapRej((e) => {
p.log.error(color.red(e.message));
return e;
});
}

private selectEffort(): Future<Error, ProviderConfig> {
const { ai } = this.config;

if (ai.provider !== "openai" || ai.auth_method.type !== "openai_oauth") {
return selectEffortForProvider(ai);
}

return resolveProvider(this.config).chain((current) =>
loading("Fetching model capabilities...", "Model capabilities fetched!", fetchModels(current.provider, current.auth_method)).chain((models) => {
const capabilities = fromOptional(models.find((model) => model.id === current.model)).chain((model) => model.openaiEffort);

return selectEffortForProvider(current, capabilities);
})
);
}
}
2 changes: 1 addition & 1 deletion src/cli/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class ModelCommand {

return loading("Fetching available models...", "Models fetched!", fetchModels(this.providerConfig.provider, this.providerConfig.auth_method))
.chain((models) => selectModelInteractively(models))
.chain((modelId) => selectEffortForProvider(withModel(this.config.ai, modelId)))
.chain((model) => selectEffortForProvider(withModel(this.providerConfig, model.id), model.openaiEffort))
.chain((ai) => saveConfig({ ...this.config, ai }))
.map(() => p.outro(color.green("Model updated successfully!")))
.mapRej((e) => {
Expand Down
2 changes: 1 addition & 1 deletion src/cli/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ class Setup {

private finalizeAfterModels(authMethod: ProviderConfig["auth_method"], models: Model[]): Future<Error, void> {
return selectModelInteractively(models)
.chain((modelId) => selectEffortForProvider(seedProviderConfig(this.preferences.provider, modelId, authMethod)))
.chain((model) => selectEffortForProvider(seedProviderConfig(this.preferences.provider, model.id, authMethod), model.openaiEffort))
.chain((ai) => saveConfig(this.buildConfig(ai)))
.map(() => {
p.outro(color.green("Setup complete!"));
Expand Down
29 changes: 24 additions & 5 deletions src/domain/commit/models.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
export { fetchModels };

import { Future } from "@/libs/future";
import { Model, type ProviderConfig } from "@/domain/config/config";
import { OPENAI_EFFORTS, type Model, type OpenAIEffort, type OpenAIModelEffort, type ProviderConfig } from "@/domain/config/config";
import { getOpenAIAccessToken } from "@/infra/auth/openai";
import { anthropicOAuthHeaders } from "@/infra/auth/anthropic";
import { Just, Nothing, type Maybe } from "@/libs/maybe";

import OpenAI from "openai";

type CodexModel = {
readonly slug: string;
readonly display_name: string;
readonly description: string;
readonly default_reasoning_level?: string;
readonly supported_reasoning_levels?: readonly { readonly effort: string }[];
};

const isOpenAIEffort = (value: string): value is OpenAIEffort => OPENAI_EFFORTS.some((effort) => effort === value);

const openAIEffortFor = (model: CodexModel): Maybe<OpenAIModelEffort> => {
const [first, ...rest] = (model.supported_reasoning_levels ?? []).map(({ effort }) => effort).filter(isOpenAIEffort);
if (first === undefined) return Nothing();

const options: [OpenAIEffort, ...OpenAIEffort[]] = [first, ...rest];
const defaultValue = options.find((effort) => effort === model.default_reasoning_level) ?? options.find((effort) => effort === "medium") ?? first;
return Just({ options, defaultValue });
};

const fetchOpenAIModelsWithApiKey = (apiKey: string): Future<Error, Model[]> =>
Expand All @@ -24,7 +38,7 @@ const fetchOpenAIModelsWithApiKey = (apiKey: string): Future<Error, Model[]> =>
return models
.filter((m) => m.id.startsWith("gpt-") || m.id.startsWith("o"))
.sort((a, b) => a.id.localeCompare(b.id))
.map((m) => ({ id: m.id, description: "" }));
.map((m) => ({ id: m.id, description: "", openaiEffort: Nothing<OpenAIModelEffort>() }));
});

const fetchOpenAIModelsWithOAuth = (tokens: ProviderConfig["auth_method"]["content"]): Future<Error, Model[]> =>
Expand All @@ -41,7 +55,9 @@ const fetchOpenAIModelsWithOAuth = (tokens: ProviderConfig["auth_method"]["conte
}

const data = (await response.json()) as { models: CodexModel[] };
return data.models.sort((a, b) => a.slug.localeCompare(b.slug)).map((m) => ({ id: m.slug, description: m.description }));
return data.models
.sort((a, b) => a.slug.localeCompare(b.slug))
.map((m) => ({ id: m.slug, description: m.description, openaiEffort: openAIEffortFor(m) }));
})
);

Expand Down Expand Up @@ -76,7 +92,8 @@ const fetchGeminiModels = (authMethod: ProviderConfig["auth_method"]): Future<Er
const data = (await response.json()) as { models?: { readonly name: string; readonly description?: string }[] };
return (data.models || []).map((m) => ({
id: m.name.replace("models/", ""),
description: m.description || ""
description: m.description || "",
openaiEffort: Nothing<OpenAIModelEffort>()
}));
});

Expand Down Expand Up @@ -110,7 +127,9 @@ const fetchAnthropicModels = (authMethod: ProviderConfig["auth_method"]): Future
data?: Array<{ id: string; display_name?: string }>;
};

return (data.data ?? []).sort((a, b) => a.id.localeCompare(b.id)).map((m) => ({ id: m.id, description: m.display_name ?? "" }));
return (data.data ?? [])
.sort((a, b) => a.id.localeCompare(b.id))
.map((m) => ({ id: m.id, description: m.display_name ?? "", openaiEffort: Nothing<OpenAIModelEffort>() }));
});

const fetchModels = (provider: ProviderConfig["provider"], authMethod: ProviderConfig["auth_method"]): Future<Error, Model[]> => {
Expand Down
16 changes: 11 additions & 5 deletions src/domain/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {
type AuthMethod,
type ProviderConfig,
type OpenAIEffort,
type OpenAIModelEffort,
type AnthropicEffort,
type GeminiEffort,
type Model,
Expand All @@ -25,6 +26,7 @@ export {
import * as s from "@/libs/json/schema";

import { absurd } from "@/libs/types";
import { type Maybe } from "@/libs/maybe";
import { ThinkingLevel } from "@google/genai";

import type OpenAIPkg from "openai";
Expand Down Expand Up @@ -78,6 +80,10 @@ const ANTHROPIC_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const sat
const GEMINI_EFFORTS = [ThinkingLevel.MINIMAL, ThinkingLevel.LOW, ThinkingLevel.MEDIUM, ThinkingLevel.HIGH] as const satisfies readonly ThinkingLevel[];

type OpenAIEffort = (typeof OPENAI_EFFORTS)[number];
type OpenAIModelEffort = {
readonly options: readonly [OpenAIEffort, ...OpenAIEffort[]];
readonly defaultValue: OpenAIEffort;
};
type AnthropicEffort = (typeof ANTHROPIC_EFFORTS)[number];
type GeminiEffort = (typeof GEMINI_EFFORTS)[number];

Expand Down Expand Up @@ -123,8 +129,8 @@ const Config = s.object({
});
type Config = s.Infer<typeof Config>;

const Model = s.object({
id: s.string,
description: s.string
});
type Model = s.Infer<typeof Model>;
type Model = {
readonly id: string;
readonly description: string;
readonly openaiEffort: Maybe<OpenAIModelEffort>;
};
8 changes: 4 additions & 4 deletions src/domain/llm/effort.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export { seedProviderConfig, withModel, selectEffortForProvider };

import { type Future } from "@/libs/future";
import { type ProviderConfig, type OpenAIEffort, type AnthropicEffort, type GeminiEffort } from "@/domain/config/config";
import { Nothing } from "@/libs/maybe";
import { type ProviderConfig, type OpenAIEffort, type OpenAIModelEffort, type AnthropicEffort, type GeminiEffort } from "@/domain/config/config";
import { Nothing, type Maybe } from "@/libs/maybe";
import { selectOpenAIEffort, selectAnthropicEffort, selectGeminiEffort } from "@/infra/ui/effort-picker";
import { absurd } from "@/libs/types";

Expand Down Expand Up @@ -32,10 +32,10 @@ const withModel = (ai: ProviderConfig, model: string): ProviderConfig => {
}
};

const selectEffortForProvider = (current: ProviderConfig): Future<Error, ProviderConfig> => {
const selectEffortForProvider = (current: ProviderConfig, modelEffort: Maybe<OpenAIModelEffort> = Nothing()): Future<Error, ProviderConfig> => {
switch (current.provider) {
case "openai":
return selectOpenAIEffort(current.model, current.effort).map(
return selectOpenAIEffort(current.model, current.effort, modelEffort).map(
(effort): ProviderConfig => ({
provider: "openai",
model: current.model,
Expand Down
19 changes: 14 additions & 5 deletions src/domain/llm/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,25 +59,34 @@ type BranchNameSuggestions = {
type ProviderGeneratedContent = {
readonly text: string;
readonly tokens: Maybe<TokenUsage>;
readonly effectiveEffort: Maybe<string>;
};

const modelRequestMetadata = (config: ProviderConfig): ModelRequestMetadata => {
const modelRequestMetadata = (config: ProviderConfig, effectiveEffort: Maybe<string>): ModelRequestMetadata => {
switch (config.provider) {
case "openai":
return { provider: config.provider, model: config.model, effort: config.effort.maybe<string>("provider default", (effort) => effort) };
return {
provider: config.provider,
model: config.model,
effort: effectiveEffort.withDefault(config.effort.maybe<string>("provider default", (effort) => effort))
};
case "gemini":
case "anthropic":
return { provider: config.provider, model: config.model, effort: config.effort.maybe<string>("medium", (effort) => effort) };
return {
provider: config.provider,
model: config.model,
effort: effectiveEffort.withDefault(config.effort.maybe<string>("medium", (effort) => effort))
};
}
};

const withRequestMetadata = (config: ProviderConfig, f: Future<Error, ProviderGeneratedContent>): Future<Error, GeneratedContent> => {
const startedAt = Date.now();
return f.map(({ text, tokens }) => ({
return f.map(({ text, tokens, effectiveEffort }) => ({
text,
metadata: {
durationMs: Date.now() - startedAt,
model: modelRequestMetadata(config),
model: modelRequestMetadata(config, effectiveEffort),
tokens
}
}));
Expand Down
8 changes: 5 additions & 3 deletions src/infra/llm/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { anthropicOAuthHeaders, CLAUDE_CODE_SYSTEM_PROMPT } from "@/infra/auth/a
import { absurd } from "@/libs/types";
import { extractResponse } from "@/domain/llm/response-parser";
import { unsupportedAuth } from "@/domain/llm/auth-error";
import { Just, fromOptional, type Maybe } from "@/libs/maybe";
import { Just, Nothing, fromOptional, type Maybe } from "@/libs/maybe";

type AnthropicConfig = Extract<Config["ai"], { provider: "anthropic" }>;
type SystemParam = NonNullable<Anthropic.MessageStreamParams["system"]>;
Expand Down Expand Up @@ -66,7 +66,8 @@ const callAnthropicWithApiKey = (
.chain((message) =>
extractResponse({ text: Just(extractAnthropicText(message.content)) }).map((text) => ({
text,
tokens: Just(toTokenUsage(message.usage))
tokens: Just(toTokenUsage(message.usage)),
effectiveEffort: Nothing()
}))
);

Expand All @@ -92,7 +93,8 @@ const callAnthropicWithSetupToken = (
.chain((message) =>
extractResponse({ text: Just(extractAnthropicText(message.content)) }).map((text) => ({
text,
tokens: Just(toTokenUsage(message.usage))
tokens: Just(toTokenUsage(message.usage)),
effectiveEffort: Nothing()
}))
);

Expand Down
3 changes: 2 additions & 1 deletion src/infra/llm/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ const extractGeminiText = (response: GenerateContentResponse): string =>

const toGeneratedContent = (response: GenerateContentResponse): ProviderGeneratedContent => ({
text: extractGeminiText(response),
tokens: toTokenUsage(response.usageMetadata)
tokens: toTokenUsage(response.usageMetadata),
effectiveEffort: Nothing()
});

const getAuthCredentials = (config: Config): Maybe<GeminiAuthCredentials> => {
Expand Down
62 changes: 45 additions & 17 deletions src/infra/llm/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ import { getOpenAIAccessToken } from "@/infra/auth/openai";
import { extractResponse } from "@/domain/llm/response-parser";
import { unsupportedAuth } from "@/domain/llm/auth-error";
import { absurd } from "@/libs/types";
import { Just, fromOptional, type Maybe } from "@/libs/maybe";
import { Just, Nothing, fromOptional, type Maybe } from "@/libs/maybe";

type OpenAIConfig = Extract<Config["ai"], { provider: "openai" }>;
type StreamBundle = {
response: OpenAI.Responses.Response;
doneEventText: string;
deltaSnapshotText: string;
attemptedEffort: Maybe<OpenAIEffort>;
};

const extractStreamText = (bundle: StreamBundle): Maybe<string> => {
Expand Down Expand Up @@ -47,34 +48,61 @@ const buildStreamParams = (model: string, effort: Maybe<OpenAIEffort>, params: G
return openaiReasoning(effort).maybe(core, (r) => ({ ...core, reasoning: r }));
};

const isUnsupportedEffort = (error: unknown, effort: OpenAIEffort): boolean => {
if (!(error instanceof OpenAI.BadRequestError)) return false;

const typedError = error.param === "reasoning.effort" && error.code === "unsupported_value";
const quotedEfforts = [`'${effort}'`, `"${effort}"`, `\`${effort}\``];
const codexError =
quotedEfforts.some((quotedEffort) => error.message.includes(`Unsupported value: ${quotedEffort} is not supported`)) &&
error.message.includes("Supported values are:");
return typedError || codexError;
};

const readOpenAIStream = async (client: OpenAI, model: string, effort: Maybe<OpenAIEffort>, params: GenerateContentParams): Promise<StreamBundle> => {
const stream = client.responses.stream(buildStreamParams(model, effort, params));

let deltaSnapshotText = "";
let doneEventText = "";

stream.on("response.output_text.delta", (event) => {
deltaSnapshotText = event.snapshot;
});

stream.on("response.output_text.done", (event) => {
doneEventText = event.text;
});

const response = await stream.finalResponse();
return {
response,
doneEventText,
deltaSnapshotText,
attemptedEffort: effort
};
};

const callOpenAIStream = (
client: OpenAI,
model: string,
effort: Maybe<OpenAIEffort>,
params: GenerateContentParams
): Future<Error, ProviderGeneratedContent> =>
Future.attemptP(async () => {
const stream = client.responses.stream(buildStreamParams(model, effort, params));

let deltaSnapshotText = "";
let doneEventText = "";

stream.on("response.output_text.delta", (event) => {
deltaSnapshotText = event.snapshot;
});

stream.on("response.output_text.done", (event) => {
doneEventText = event.text;
});

const response = await stream.finalResponse();
return { response, doneEventText, deltaSnapshotText };
try {
return await readOpenAIStream(client, model, effort, params);
} catch (error) {
const selectedEffort = effort.asNullable();
if (selectedEffort === null || !isUnsupportedEffort(error, selectedEffort)) throw error;
return await readOpenAIStream(client, model, Nothing<OpenAIEffort>(), params);
}
})
.mapRej((error) => new Error(`Failed to create OpenAI response: ${error instanceof Error ? error.message : String(error)}`))
.chain((bundle) =>
extractResponse({ text: extractStreamText(bundle) }).map((text) => ({
text,
tokens: fromOptional(bundle.response.usage).map(toTokenUsage)
tokens: fromOptional(bundle.response.usage).map(toTokenUsage),
effectiveEffort: Just(bundle.attemptedEffort.maybe<string>("provider default", (value) => value))
}))
);

Expand Down
Loading