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
65 changes: 63 additions & 2 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("treats ultrathink as a prompt keyword instead of a session effort", () => {
it.effect("maps ultrathink on Sonnet 4.6 to effort=high with max thinking budget", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
Expand Down Expand Up @@ -577,7 +577,11 @@ describe("ClaudeAdapterLive", () => {
});

const createInput = harness.getLastCreateQueryInput();
assert.equal(createInput?.options.effort, undefined);
// Sonnet 4.6 has no "max" effort, so ultrathink collapses to "high".
assert.equal(createInput?.options.effort, "high");
// Thinking budget is bumped to the ultrathink default.
assert.equal(createInput?.options.maxThinkingTokens, 63999);
// Prompt prefix is still applied on top of the SDK boost.
const promptText = yield* Effect.promise(() => readFirstPromptText(createInput));
assert.equal(promptText, "Ultrathink:\nInvestigate the edge cases");
}).pipe(
Expand All @@ -586,6 +590,63 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("maps ultrathink on Opus 4.7 to effort=max with max thinking budget", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
yield* adapter.startSession({
threadId: THREAD_ID,
provider: "claudeAgent",
model: "claude-opus-4-7",
runtimeMode: "full-access",
modelOptions: {
claudeAgent: {
effort: "ultrathink",
},
},
});

const createInput = harness.getLastCreateQueryInput();
// Opus 4.7 supports "max", so ultrathink gets the top effort level.
assert.equal(createInput?.options.effort, "max");
assert.equal(createInput?.options.maxThinkingTokens, 63999);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("preserves user-provided maxThinkingTokens when higher than ultrathink default", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
yield* adapter.startSession({
threadId: THREAD_ID,
provider: "claudeAgent",
model: "claude-opus-4-7",
runtimeMode: "full-access",
providerOptions: {
claudeAgent: {
maxThinkingTokens: 90000,
},
},
modelOptions: {
claudeAgent: {
effort: "ultrathink",
},
},
});

const createInput = harness.getLastCreateQueryInput();
assert.equal(createInput?.options.effort, "max");
// User override is higher than the ultrathink default, so it passes through.
assert.equal(createInput?.options.maxThinkingTokens, 90000);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("embeds image attachments in Claude user messages", () => {
const baseDir = mkdtempSync(path.join(os.tmpdir(), "claude-attachments-"));
const harness = makeHarness({
Expand Down
21 changes: 13 additions & 8 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ import {
} from "@okcode/contracts";
import {
applyClaudePromptEffortPrefix,
getEffectiveClaudeCodeEffort,
getReasoningEffortOptions,
resolveClaudeUltrathinkSdkConfig,
resolveReasoningEffortForProvider,
supportsClaudeFastMode,
supportsClaudeThinkingToggle,
Expand Down Expand Up @@ -2811,7 +2811,12 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
supportsClaudeThinkingToggle(input.model)
? input.modelOptions.claudeAgent.thinking
: undefined;
const effectiveEffort = getEffectiveClaudeCodeEffort(effort);
const { effort: sdkEffort, maxThinkingTokens: sdkMaxThinkingTokens } =
resolveClaudeUltrathinkSdkConfig(
input.model,
effort,
providerOptions?.maxThinkingTokens ?? null,
);
const permissionMode =
toPermissionMode(providerOptions?.permissionMode) ??
(input.runtimeMode === "full-access" ? "bypassPermissions" : undefined);
Expand All @@ -2833,13 +2838,13 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
...(input.model ? { model: input.model } : {}),
pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude",
settingSources: [...CLAUDE_SETTING_SOURCES],
...(effectiveEffort ? { effort: effectiveEffort } : {}),
...(sdkEffort ? { effort: sdkEffort } : {}),
...(permissionMode ? { permissionMode } : {}),
...(permissionMode === "bypassPermissions"
? { allowDangerouslySkipPermissions: true }
: {}),
...(providerOptions?.maxThinkingTokens !== undefined
? { maxThinkingTokens: providerOptions.maxThinkingTokens }
...(sdkMaxThinkingTokens !== undefined
? { maxThinkingTokens: sdkMaxThinkingTokens }
: {}),
...(Object.keys(settings).length > 0 ? { settings } : {}),
...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}),
Expand Down Expand Up @@ -2930,10 +2935,10 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
config: {
...(input.model ? { model: input.model } : {}),
...(input.cwd ? { cwd: input.cwd } : {}),
...(effectiveEffort ? { effort: effectiveEffort } : {}),
...(sdkEffort ? { effort: sdkEffort } : {}),
...(permissionMode ? { permissionMode } : {}),
...(providerOptions?.maxThinkingTokens !== undefined
? { maxThinkingTokens: providerOptions.maxThinkingTokens }
...(sdkMaxThinkingTokens !== undefined
? { maxThinkingTokens: sdkMaxThinkingTokens }
: {}),
...(fastMode ? { fastMode: true } : {}),
},
Expand Down
46 changes: 36 additions & 10 deletions apps/server/src/provider/codexConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import {
import { Effect, FileSystem, Result } from "effect";
import { parse as parseToml } from "toml";

import { probeCodexLocalBackends } from "./codexLocalBackendProbe.ts";

export interface CodexConfigReadOptions {
readonly homePath?: string | null | undefined;
readonly env?: NodeJS.ProcessEnv | undefined;
readonly probeLocalBackends?: boolean | undefined;
}

function emptyCodexConfigSummary(): ServerCodexConfigSummary {
Expand Down Expand Up @@ -182,19 +185,42 @@ export const readCodexConfigSummary = (options: CodexConfigReadOptions = {}) =>
const fileSystem = yield* FileSystem.FileSystem;
const configPath = resolveCodexConfigPath(options);
const exists = yield* fileSystem.exists(configPath).pipe(Effect.orElseSucceed(() => false));
if (!exists) {
return emptyCodexConfigSummary();
}

const content = yield* fileSystem.readFileString(configPath).pipe(Effect.result);
if (Result.isFailure(content)) {
return {
...emptyCodexConfigSummary(),
parseError: getParseErrorMessage(content.failure),
};
const baseSummary: ServerCodexConfigSummary = yield* Effect.gen(function* () {
if (!exists) {
return emptyCodexConfigSummary();
}

const content = yield* fileSystem.readFileString(configPath).pipe(Effect.result);
if (Result.isFailure(content)) {
return {
...emptyCodexConfigSummary(),
parseError: getParseErrorMessage(content.failure),
} satisfies ServerCodexConfigSummary;
}

return summarizeCodexConfigToml(content.success);
});

if (options.probeLocalBackends !== true) {
return baseSummary;
}

return summarizeCodexConfigToml(content.success);
const probes = yield* probeCodexLocalBackends();

return {
...baseSummary,
detectedLocalBackends: {
ollama:
probes.ollama.modelCount !== undefined
? { reachable: probes.ollama.reachable, modelCount: probes.ollama.modelCount }
: { reachable: probes.ollama.reachable },
lmstudio:
probes.lmstudio.modelCount !== undefined
? { reachable: probes.lmstudio.reachable, modelCount: probes.lmstudio.modelCount }
: { reachable: probes.lmstudio.reachable },
},
} satisfies ServerCodexConfigSummary;
});

export function usesOpenAiLoginForSelectedCodexBackend(summary: ServerCodexConfigSummary): boolean {
Expand Down
133 changes: 133 additions & 0 deletions apps/server/src/provider/codexLocalBackendProbe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { Effect } from "effect";

export interface LocalBackendProbeResult {
readonly reachable: boolean;
readonly modelCount?: number;
readonly error?: string;
}

export interface LocalBackendProbes {
readonly ollama: LocalBackendProbeResult;
readonly lmstudio: LocalBackendProbeResult;
}

const DEFAULT_PROBE_TIMEOUT_MS = 1_500;

const OLLAMA_TAGS_URL = "http://localhost:11434/api/tags";
const LM_STUDIO_MODELS_URL = "http://localhost:1234/v1/models";

function isSuppressedByEnv(): boolean {
const env = process.env;
return env.OKCODE_DISABLE_LOCAL_BACKEND_PROBES === "1" || env.VITEST === "true";
}

function toErrorMessage(cause: unknown, fallback: string): string {
if (cause instanceof Error && cause.message.trim().length > 0) {
if (cause.name === "AbortError") {
return "timeout";
}
return cause.message;
}
if (typeof cause === "string" && cause.trim().length > 0) {
return cause;
}
return fallback;
}

function readModelCount(data: unknown, key: "models" | "data"): number | undefined {
if (!data || typeof data !== "object") {
return undefined;
}
const value = (data as Record<string, unknown>)[key];
if (Array.isArray(value)) {
return value.length;
}
return undefined;
}

async function probeHttp(input: {
readonly url: string;
readonly modelsKey: "models" | "data";
readonly timeoutMs: number;
}): Promise<LocalBackendProbeResult> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), input.timeoutMs);
try {
const response = await fetch(input.url, {
method: "GET",
signal: controller.signal,
headers: { accept: "application/json" },
});
if (!response.ok) {
return {
reachable: false,
error: `HTTP ${response.status}`,
};
}
try {
const body: unknown = await response.json();
const modelCount = readModelCount(body, input.modelsKey);
return modelCount !== undefined ? { reachable: true, modelCount } : { reachable: true };
} catch (cause) {
// Server responded 2xx but body wasn't JSON — still counts as reachable.
return {
reachable: true,
error: toErrorMessage(cause, "Non-JSON response"),
};
}
} catch (cause) {
return {
reachable: false,
error: toErrorMessage(cause, "Network error"),
};
} finally {
clearTimeout(timeout);
}
}

export interface ProbeLocalBackendOptions {
readonly timeoutMs?: number | undefined;
}

const UNREACHABLE_STUB: LocalBackendProbeResult = { reachable: false };

export const probeOllama = (
options: ProbeLocalBackendOptions = {},
): Effect.Effect<LocalBackendProbeResult> => {
if (isSuppressedByEnv()) {
return Effect.succeed(UNREACHABLE_STUB);
}
return Effect.promise(() =>
probeHttp({
url: OLLAMA_TAGS_URL,
modelsKey: "models",
timeoutMs: options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS,
}),
);
};

export const probeLmStudio = (
options: ProbeLocalBackendOptions = {},
): Effect.Effect<LocalBackendProbeResult> => {
if (isSuppressedByEnv()) {
return Effect.succeed(UNREACHABLE_STUB);
}
return Effect.promise(() =>
probeHttp({
url: LM_STUDIO_MODELS_URL,
modelsKey: "data",
timeoutMs: options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS,
}),
);
};

export const probeCodexLocalBackends = (
options: ProbeLocalBackendOptions = {},
): Effect.Effect<LocalBackendProbes> =>
Effect.all(
{
ollama: probeOllama(options),
lmstudio: probeLmStudio(options),
},
{ concurrency: "unbounded" },
);
4 changes: 4 additions & 0 deletions apps/server/src/wsServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ const defaultCodexConfigSummary = {
selectedModelProviderId: null,
entries: [],
parseError: null,
detectedLocalBackends: {
ollama: { reachable: false },
lmstudio: { reachable: false },
},
} as const;

const expectedServerBuildInfo = expect.objectContaining({
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1640,7 +1640,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
case WS_METHODS.serverGetConfig:
const keybindingsConfig = yield* keybindingsManager.loadConfigState;
const providers = yield* getProviderStatuses();
const codexConfig = yield* readCodexConfigSummary();
const codexConfig = yield* readCodexConfigSummary({ probeLocalBackends: true });
return {
cwd,
keybindingsConfigPath,
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5415,6 +5415,10 @@ export default function ChatView({
: selectableProviders
).includes(provider.provider),
)}
codexSelectedModelProviderId={
serverConfigQuery.data?.codexConfig?.selectedModelProviderId ?? null
}
openclawGatewayUrl={settings.openclawGatewayUrl}
{...(composerProviderState.modelPickerIconClassName
? {
activeProviderIconClassName:
Expand Down
Loading
Loading