import React, { useState, useCallback, useEffect, createContext, useContext } from "react"; import TextInput from "./text-input.tsx"; import { Config, Auth, apiKeyFromAuth, hasExistingAuthForBaseUrl, readAuthForModel, } from "../config.ts"; import { useColor } from "../theme.ts"; import OpenAI from "openai"; import { trackTokens } from "../token-tracker.ts"; import { SetApiKey } from "./set-api-key.tsx"; import { KbShortcutPanel } from "./kb-select/kb-shortcut-panel.tsx"; import { Item, ShortcutArray } from "./kb-select/kb-shortcut-select.tsx"; import { router, Back } from "../router.tsx"; import { providerForBaseUrl } from "../providers.ts"; import * as logger from "../logger.ts"; import { parse } from "shell-quote"; import { getDefaultOpenaiClient } from "../compilers/openai.ts"; import { CODEX_OAUTH_FILE, hasCodexOAuthTokens, openDefaultBrowser, pollCodexDeviceAuthorization, startCodexDeviceAuthorization, writeCodexOAuthTokens, } from "../codex-oauth.ts"; import { Span } from "paintcannon-react"; import { TerminalFlex } from "./terminal-flex.tsx"; type Model = Config["models"][number]; type ValidationResult = | { valid: true; } | { valid: false; error: string; }; type AddModelStep = { title: string; prompt: string; defaultValue?: string; parse: (val: string) => T; validate: (val: string) => ValidationResult; onSubmit: (t: T) => any; children: React.ReactNode; }; type ModelStepRoute = T & { renderExamples: boolean; done: (data: Model) => any; cancel: () => any; config: Config | null; }; type FullFlowRouteData = { baseUrl: ModelStepRoute<{}>; authAsk: ModelStepRoute<{ baseUrl: string; }>; envVar: ModelStepRoute<{ baseUrl: string; }>; command: ModelStepRoute<{ baseUrl: string; }>; apiKey: ModelStepRoute<{ baseUrl: string; }>; codexOAuth: ModelStepRoute<{ baseUrl: string; }>; postAuth: ModelStepRoute<{ baseUrl: string; auth?: Auth; }>; model: ModelStepRoute<{ baseUrl: string; auth?: Auth; }>; testConnection: ModelStepRoute<{ baseUrl: string; auth?: Auth; model: string; }>; nickname: ModelStepRoute<{ baseUrl: string; auth?: Auth; model: string; metadata: ModelMetadata; nickname?: string; }>; context: ModelStepRoute<{ baseUrl: string; auth?: Auth; model: string; nickname: string; metadata: ModelMetadata; }>; }; const errorContext = createContext<{ setErrorMessage: (m: string) => any; errorMessage: string; }>({ errorMessage: "", setErrorMessage: () => {}, }); const fullFlow = router(); type AuthAskRoute = "apiKey" | "envVar" | "command"; type AuthAskSelection = AuthAskRoute | "back"; const baseUrl = fullFlow .withRoutes("authAsk", "baseUrl", "postAuth", "codexOAuth") .build("baseUrl", to => props => { return ( title="What's the base URL for the API you're connecting to?" prompt="Base URL:" parse={val => val} validate={() => ({ valid: true, })} onSubmit={async baseUrl => { const provider = providerForBaseUrl(baseUrl); if (provider?.type === "codex") { if (await hasCodexOAuthTokens()) { to.postAuth({ ...props, baseUrl, auth: { type: "codex", }, }); } else { to.codexOAuth({ ...props, baseUrl, }); } return; } const hasExistingAuth = await hasExistingAuthForBaseUrl(baseUrl, props.config); if (hasExistingAuth) { to.postAuth({ ...props, baseUrl, }); } else { to.authAsk({ ...props, baseUrl, }); } }} > {props.renderExamples && ( (For example, for Moonshot's Kimi K2 API, https://api.moonshot.ai/v1) )} You can usually find this information in your inference provider's documentation. ); }); function AuthAsk( props: FullFlowRouteData["authAsk"] & Pick, "back"> & { onSelect: (route: AuthAskRoute) => void; }, ) { const provider = providerForBaseUrl(props.baseUrl); const shortcutItems = [ { type: "key" as const, mapping: { a: { label: "Enter an API key", value: "apiKey", }, e: { label: "I have an existing environment variable I use...", value: "envVar", }, c: { label: "Use a command (e.g. pass, op, gopass)...", value: "command", }, b: { label: "Back", value: "back", }, }, }, ] satisfies ShortcutArray; const onSelect = useCallback((item: Item) => { if (item.value === "back") props.back(); else props.onSelect(item.value); }, []); return ( {provider && ( It looks like you don't have the default {provider.envVar} environment variable defined in your current shell. How do you want to authenticate with {provider.name}? )} ); } function CodexOAuthStep({ cancel, onComplete }: { cancel: () => void; onComplete: () => void }) { const [status, setStatus] = useState< | { type: "starting"; } | { type: "waiting"; url: string; code: string; opened: boolean; } | { type: "error"; message: string; } >({ type: "starting", }); useEffect(() => { const abortController = new AbortController(); async function authorize() { const device = await startCodexDeviceAuthorization(); if (abortController.signal.aborted) return; if (!device.success) { setStatus({ type: "error", message: device.error, }); return; } const openedResult = await openDefaultBrowser(device.data.verificationUri); const opened = openedResult.success ? openedResult.data : false; if (abortController.signal.aborted) return; setStatus({ type: "waiting", url: device.data.verificationUri, code: device.data.userCode, opened, }); const tokens = await pollCodexDeviceAuthorization(device.data, abortController.signal); if (abortController.signal.aborted) return; if (!tokens.success) { setStatus({ type: "error", message: tokens.error, }); return; } const written = await writeCodexOAuthTokens(tokens.data); if (abortController.signal.aborted) return; if (!written.success) { setStatus({ type: "error", message: written.error, }); return; } onComplete(); } authorize(); return () => abortController.abort(); }, [onComplete]); return ( ChatGPT Codex authorization {status.type === "starting" && Requesting an authorization code...} {status.type === "waiting" && ( <> {status.opened ? "Opened your browser. If it did not appear, open this URL manually:" : "Could not open your browser automatically. Open this URL manually:"} {status.url} Code:{" "} {status.code} Waiting for authorization. Press ESC to cancel. )} {status.type === "error" && ( <> Authorization failed: {status.message} Press ESC to go back. )} {status.type !== "error" && ( Credentials will be saved to {CODEX_OAUTH_FILE} )} ); } const codexOAuth = fullFlow .withRoutes("codexOAuth", "authAsk", "postAuth") .build("codexOAuth", to => props => { return ( to.postAuth({ ...props, auth: { type: "codex", }, }) } /> ); }); const envVar = fullFlow.withRoutes("authAsk", "envVar", "postAuth").build("envVar", to => props => { return ( to.authAsk(props)}> title="What environment variable should Octo read to get the API key?" prompt="Environment variable name:" parse={val => val} validate={val => { if (process.env[val]) return { valid: true, }; return { valid: false, error: ` Env var ${val} isn't defined in your current shell. Do you need to re-source your .bashrc or .zshrc? `.trim(), }; }} onSubmit={envVar => to.postAuth({ ...props, auth: { type: "env", name: envVar, }, }) } > {props.renderExamples && ( (For example, MOONSHOT_API_KEY) )} You can typically find your API key on your account or settings page on your inference provider's website. {props.renderExamples && ( <> After getting an API key, make sure to export it in your shell; for example: export MOONSHOT_API_KEY="your-api-key-here" (If you're running a local LLM, you can use any non-empty env var.) )} ); }); const command = fullFlow .withRoutes("authAsk", "command", "postAuth") .build("command", to => props => { return ( to.authAsk(props)}> title="What command should Octo run to get the API key?" prompt="Command:" parse={val => { const parsed = parse(val); // shell-quote returns an array that may include shell operators (objects) // We filter to keep only string arguments for the command return parsed.filter((item): item is string => typeof item === "string"); }} validate={val => { const parsed = parse(val); // Detect shell operators (pipes, redirects, etc.) which shell-quote parses as objects const hasOperators = parsed.some(item => typeof item !== "string"); if (hasOperators) { return { valid: false, error: "Shell operators like pipes (|) and redirects (>, <) aren't supported. Enter only the command and its arguments.", }; } const [commandName] = parsed; if (!commandName) { return { valid: false, error: "Command can't be empty", }; } return { valid: true, }; }} onSubmit={command => to.postAuth({ ...props, auth: { type: "command", command, }, }) } > Enter the command and arguments separated by spaces. The command should output only the API key to stdout. {props.renderExamples && ( <> Examples: pass show openai/api-key op read "op://vault/openai/key" gopass show -o openai/key )} ); }); type Transitions = { back: () => void; onSubmit: (data: T) => void; }; const apiKey = fullFlow.withRoutes("apiKey", "authAsk", "postAuth").build("apiKey", to => props => { return ( to.postAuth(props)} onCancel={() => to.authAsk(props)} /> ); }); function PostAuth( props: FullFlowRouteData["postAuth"] & { handleAuth: () => void; }, ) { useEffect(() => { props.handleAuth(); }, []); return <>; } function Model(props: FullFlowRouteData["model"] & Transitions) { return ( title="What's the model string for the API you're using?" prompt="Model string:" parse={val => val} validate={val => { if (props.baseUrl === "https://synthetic.new") { if (!val.startsWith("hf:")) { return { valid: false, error: `Synthetic model names need to be prefixed with "hf:" (without the quotes)`, }; } } return { valid: true, }; }} onSubmit={props.onSubmit} > {props.renderExamples && ( (For example, to use Kimi K2 with the Moonshot API, you would use kimi-k2-0711-preview) )} This varies by inference provider: you can typically find this information in your inference provider's documentation. ); } function TestConnection( props: FullFlowRouteData["testConnection"] & { errorNav: () => any; } & Transitions, ) { const { setErrorMessage } = useContext(errorContext); useEffect(() => { testConnection({ model: props.model, auth: props.auth, baseUrl: props.baseUrl, config: props.config, }).then(result => { if (result.valid) { props.onSubmit(result.metadata); return; } setErrorMessage("Connection failed."); props.errorNav(); }); }, [props]); return ( Testing connection... ); } const nickname = fullFlow .withRoutes("nickname", "model", "context") .build("nickname", router => props => { const defaultNickname = props.nickname || props.metadata.name?.split("/").pop()?.replace(/-/g, " ") || ""; return ( router.model(props)}> title="Let's give this model a nickname so we can easily reference it later." prompt="Nickname:" defaultValue={defaultNickname} parse={val => val} validate={() => ({ valid: true, })} onSubmit={nickname => router.context({ ...props, nickname, }) } > {props.renderExamples && ( For example, if this was set up to talk to Kimi K2, you might want to call it that. )} ); }); function formatContextTokens(tokens: number): string { const halfTokens = tokens / 2; const kValue = Math.round(halfTokens / 1024); return `${kValue}k`; } function Context(props: FullFlowRouteData["context"] & Pick, "back">) { const color = useColor(); const { baseUrl, auth, model, nickname, done, metadata } = props; const defaultContext = metadata.contextLength ? formatContextTokens(metadata.contextLength) : ""; return ( title="What's the maximum number of tokens Octo should use per request?" prompt="Maximum tokens:" defaultValue={defaultContext} parse={val => { return parseInt(val.replace("k", ""), 10) * 1024; }} validate={value => { if (value.replace("k", "").match(/^\d+$/)) return { valid: true, }; return { valid: false, error: "Couldn't parse your input as a number: please try again", }; }} onSubmit={context => { if (auth?.type === "codex") { done({ type: "codex", model, nickname, context, auth, }); return; } done({ baseUrl, model, nickname, context, auth, }); }} > You can usually find this information in the documentation for the model on your inference company's website. (This is an estimate: leave some buffer room. Best performance is often at half the number of tokens supported by the API.) Format the number in k: for example, 32k or,{" "} 64k. ); } const fullFlowRoutes = fullFlow.route({ baseUrl, envVar, command, apiKey, codexOAuth, nickname, authAsk: to => props => { return ( to[route](props)} back={() => to.baseUrl(props)} /> ); }, postAuth: to => props => { return to.model(props)} />; }, model: to => props => { return ( to.authAsk(props)} onSubmit={model => to.testConnection({ ...props, model, }) } /> ); }, testConnection: to => props => { return ( to.model(props)} errorNav={() => to.baseUrl(props)} onSubmit={metadata => to.nickname({ ...props, metadata, }) } /> ); }, context: to => props => { return to.nickname(props)} />; }, }); export function FullAddModelFlow({ onComplete, onCancel, config, }: { onComplete: (args: Model) => any; onCancel: () => any; config: Config | null; }) { const [errorMessage, setErrorMessage] = useState(""); return ( ); } type CustomModelFlowRouteData = Pick< FullFlowRouteData, "model" | "testConnection" | "nickname" | "context" >; const customModelFlow = router(); const customModelFlowRoutes = customModelFlow.route({ model: to => props => { return ( props.cancel()} onSubmit={model => to.testConnection({ ...props, model, }) } /> ); }, testConnection: to => props => { return ( to.model(props)} errorNav={() => to.model(props)} onSubmit={metadata => to.nickname({ ...props, metadata, }) } /> ); }, nickname, context: to => props => { return to.nickname(props)} />; }, }); export function CustomModelFlow({ onComplete, onCancel, baseUrl, auth, config, }: { onComplete: (args: Model) => any; onCancel: () => any; baseUrl: string; auth?: Auth; config: Config | null; }) { const [errorMessage, setErrorMessage] = useState(""); return ( ); } const customAuthDoneCtx = createContext<(auth?: Auth) => any>(() => {}); type CustomAuthFlowData = Pick & { codexOAuth: ModelStepRoute<{}>; postAuth: ModelStepRoute<{ baseUrl?: string; auth?: Auth; }>; }; type CustomAuthData = | { modelType: Exclude; baseUrl: string; } | { modelType: "codex"; }; const customAuthFlow = router(); const customCodexOAuth = customAuthFlow .withRoutes("codexOAuth", "postAuth") .build("codexOAuth", to => props => { return ( to.postAuth({ ...props, auth: { type: "codex", }, }) } /> ); }); const customAuthRoutes = customAuthFlow.route({ authAsk: to => props => { return to[route](props)} back={() => props.cancel()} />; }, envVar, command, apiKey, codexOAuth: customCodexOAuth, postAuth: _ => props => { const done = useContext(customAuthDoneCtx); useEffect(() => { done(props.auth); }, [done, props.auth]); return <>; }, }); export function CustomAuthFlow({ onComplete, onCancel, authData, config, }: { onComplete: (auth?: Auth) => any; onCancel: () => any; authData: CustomAuthData; config: Config | null; }) { const [errorMessage, setErrorMessage] = useState(""); const [hasCheckedExistingKey, setHasCheckedExistingKey] = useState(false); const authBaseUrl = authData.modelType === "codex" ? undefined : authData.baseUrl; useEffect(() => { if (!hasCheckedExistingKey) { if (authData.modelType === "codex") { hasCodexOAuthTokens().then(hasTokens => { if (hasTokens) onComplete({ type: "codex", }); else setHasCheckedExistingKey(true); }); } else { hasExistingAuthForBaseUrl(authData.baseUrl, config).then(hasAuth => { if (hasAuth) { onComplete(); } setHasCheckedExistingKey(true); }); } } }, [hasCheckedExistingKey, authData.modelType, authBaseUrl, config, onComplete]); // Show nothing while checking for existing key (will auto-complete if found) if (!hasCheckedExistingKey) { return <>; } return ( {authData.modelType === "codex" ? ( {}, cancel: onCancel, config, }} /> ) : ( {}, cancel: onCancel, baseUrl: authData.baseUrl, config, }} /> )} ); } type CustomAutofixFlowRouteData = Pick< FullFlowRouteData, | "baseUrl" | "authAsk" | "envVar" | "command" | "apiKey" | "codexOAuth" | "postAuth" | "model" | "testConnection" | "context" >; const customAutofixFlow = router(); const customAutofixRoutes = customAutofixFlow.route({ baseUrl, envVar, command, apiKey, codexOAuth, authAsk: to => props => { return ( to[route](props)} back={() => to.baseUrl(props)} /> ); }, postAuth: to => props => { return to.model(props)} />; }, model: to => props => { return ( props.cancel()} onSubmit={model => to.testConnection({ ...props, model, }) } /> ); }, testConnection: to => props => { return ( to.model(props)} errorNav={() => to.model(props)} onSubmit={metadata => to.context({ ...props, nickname: "custom-autofix", metadata, }) } /> ); }, context: to => props => { return to.model(props)} />; }, }); export function CustomAutofixFlow({ onComplete, onCancel, config, }: { onComplete: (args: Model) => any; onCancel: () => any; config: Config | null; }) { const [errorMessage, setErrorMessage] = useState(""); return ( ); } function Step(props: AddModelStep) { const { errorMessage, setErrorMessage } = useContext(errorContext); const [varValue, setVarValue] = useState(props.defaultValue || ""); const themeColor = useColor(); const onValueChange = useCallback((value: string) => { setErrorMessage(""); setVarValue(value); }, []); const onSubmit = useCallback(() => { const trimmed = varValue.trim(); if (trimmed === "") { setErrorMessage("Entry can't be empty"); return; } const validationResult = props.validate(trimmed); if (!validationResult.valid) { setVarValue(""); setErrorMessage(validationResult.error); return; } let parsed = props.parse(trimmed); props.onSubmit(parsed); }, [props, varValue]); return ( {props.title} {props.children} {props.prompt} {errorMessage && ( {errorMessage} )} ); } type ModelMetadata = { name?: string; contextLength?: number; }; type TestConnectionResult = | { valid: true; metadata: ModelMetadata; } | { valid: false; }; type MinConnectArgs = { model: string; auth?: Auth; baseUrl: string; config: Config | null; }; async function testConnection({ model, auth, baseUrl, config, }: MinConnectArgs): Promise { try { const provider = providerForBaseUrl(baseUrl); if (provider?.type === "codex") { const configuredModel = provider.models.find(candidate => candidate.model === model); return { valid: true, metadata: { name: configuredModel?.nickname ?? model, contextLength: configuredModel?.context, }, }; } const authResult = await readAuthForModel( { baseUrl, auth, }, config, ); if (!authResult.ok || authResult.auth.type !== "apiKey") return { valid: false, }; const apiKey = apiKeyFromAuth(authResult.auth); const client = getDefaultOpenaiClient({ baseUrl, apiKey, }); const testPromise = client.chat.completions.create({ model, messages: [ { role: "user", content: "Respond with the word 'hi' and only the word 'hi'", }, ], }); const metadataPromise = fetchModelMetadata(client, model); const [response, metadata] = await Promise.all([testPromise, metadataPromise]); if (response.usage) { trackTokens(model, "input", response.usage.prompt_tokens); trackTokens(model, "output", response.usage.completion_tokens); } return { valid: true, metadata, }; } catch (e) { logger.error("verbose", e); return { valid: false, }; } } async function fetchModelMetadata(client: OpenAI, model: string): Promise { try { const models = await client.models.list({ timeout: 3000, }); const modelInfo = models.data.find(m => m.id === model) as | (OpenAI.Models.Model & { name?: string; context_length?: number; }) | undefined; if (!modelInfo) { return {}; } return { name: modelInfo.name, contextLength: modelInfo.context_length, }; } catch { return {}; } }