Ver código fonte

feat: replace reasoningEffort with generic llm.extra passthrough (#57)

Users can now pass any provider-specific parameter via llm.extra:
  inkos config set llm.extra.reasoning_effort high    # OpenAI o3/o4
  inkos config set llm.extra.enable_thinking true     # Qwen
  INKOS_LLM_EXTRA_reasoning_effort=xhigh              # env var

Extra params are spread directly into the API request body.
Removes the hardcoded reasoningEffort field added earlier today.
Ma 7 meses atrás
pai
commit
a3ba207477

+ 3 - 2
packages/cli/src/commands/config.ts

@@ -23,13 +23,14 @@ configCommand
 
 
       const KNOWN_KEYS = new Set([
       const KNOWN_KEYS = new Set([
         "llm.provider", "llm.baseUrl", "llm.model", "llm.temperature",
         "llm.provider", "llm.baseUrl", "llm.model", "llm.temperature",
-        "llm.maxTokens", "llm.thinkingBudget", "llm.reasoningEffort", "llm.apiFormat", "llm.stream",
+        "llm.maxTokens", "llm.thinkingBudget", "llm.apiFormat", "llm.stream",
         "daemon.schedule.radarCron", "daemon.schedule.writeCron",
         "daemon.schedule.radarCron", "daemon.schedule.writeCron",
         "daemon.maxConcurrentBooks", "daemon.chaptersPerCycle",
         "daemon.maxConcurrentBooks", "daemon.chaptersPerCycle",
         "daemon.retryDelayMs", "daemon.cooldownAfterChapterMs",
         "daemon.retryDelayMs", "daemon.cooldownAfterChapterMs",
         "daemon.maxChaptersPerDay",
         "daemon.maxChaptersPerDay",
       ]);
       ]);
-      if (!KNOWN_KEYS.has(key)) {
+      // Allow any key under llm.extra.* (passthrough to API)
+      if (!KNOWN_KEYS.has(key) && !key.startsWith("llm.extra.")) {
         // Find closest match by edit distance on the last segment
         // Find closest match by edit distance on the last segment
         const candidates = [...KNOWN_KEYS];
         const candidates = [...KNOWN_KEYS];
         const inputParts = key.split(".");
         const inputParts = key.split(".");

+ 7 - 7
packages/core/src/llm/provider.ts

@@ -79,7 +79,7 @@ export interface LLMClient {
     readonly temperature: number;
     readonly temperature: number;
     readonly maxTokens: number;
     readonly maxTokens: number;
     readonly thinkingBudget: number;
     readonly thinkingBudget: number;
-    readonly reasoningEffort?: "low" | "medium" | "high";
+    readonly extra: Record<string, unknown>;
   };
   };
 }
 }
 
 
@@ -115,7 +115,7 @@ export function createLLMClient(config: LLMConfig): LLMClient {
     temperature: config.temperature ?? 0.7,
     temperature: config.temperature ?? 0.7,
     maxTokens: config.maxTokens ?? 8192,
     maxTokens: config.maxTokens ?? 8192,
     thinkingBudget: config.thinkingBudget ?? 0,
     thinkingBudget: config.thinkingBudget ?? 0,
-    reasoningEffort: config.reasoningEffort,
+    extra: config.extra ?? {},
   };
   };
 
 
   const apiFormat = config.apiFormat ?? "chat";
   const apiFormat = config.apiFormat ?? "chat";
@@ -220,7 +220,7 @@ export async function chatCompletion(
   const resolved = {
   const resolved = {
     temperature: options?.temperature ?? client.defaults.temperature,
     temperature: options?.temperature ?? client.defaults.temperature,
     maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
     maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
-    reasoningEffort: client.defaults.reasoningEffort,
+    extra: client.defaults.extra,
   };
   };
   const onStreamProgress = options?.onStreamProgress;
   const onStreamProgress = options?.onStreamProgress;
   const errorCtx = { baseUrl: client._openai?.baseURL ?? "(anthropic)", model };
   const errorCtx = { baseUrl: client._openai?.baseURL ?? "(anthropic)", model };
@@ -324,7 +324,7 @@ async function chatCompletionOpenAIChat(
   client: OpenAI,
   client: OpenAI,
   model: string,
   model: string,
   messages: ReadonlyArray<LLMMessage>,
   messages: ReadonlyArray<LLMMessage>,
-  options: { readonly temperature: number; readonly maxTokens: number; readonly reasoningEffort?: "low" | "medium" | "high" },
+  options: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
   webSearch?: boolean,
   webSearch?: boolean,
   onStreamProgress?: OnStreamProgress,
   onStreamProgress?: OnStreamProgress,
 ): Promise<LLMResponse> {
 ): Promise<LLMResponse> {
@@ -336,7 +336,7 @@ async function chatCompletionOpenAIChat(
     max_tokens: options.maxTokens,
     max_tokens: options.maxTokens,
     stream: true,
     stream: true,
     ...(webSearch ? { web_search_options: { search_context_size: "medium" as const } } : {}),
     ...(webSearch ? { web_search_options: { search_context_size: "medium" as const } } : {}),
-    ...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
+    ...options.extra,
   };
   };
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   const stream = await client.chat.completions.create(createParams) as any;
   const stream = await client.chat.completions.create(createParams) as any;
@@ -386,7 +386,7 @@ async function chatCompletionOpenAIChatSync(
   client: OpenAI,
   client: OpenAI,
   model: string,
   model: string,
   messages: ReadonlyArray<LLMMessage>,
   messages: ReadonlyArray<LLMMessage>,
-  options: { readonly temperature: number; readonly maxTokens: number; readonly reasoningEffort?: "low" | "medium" | "high" },
+  options: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
   _webSearch?: boolean,
   _webSearch?: boolean,
 ): Promise<LLMResponse> {
 ): Promise<LLMResponse> {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -396,7 +396,7 @@ async function chatCompletionOpenAIChatSync(
     temperature: options.temperature,
     temperature: options.temperature,
     max_tokens: options.maxTokens,
     max_tokens: options.maxTokens,
     stream: false,
     stream: false,
-    ...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
+    ...options.extra,
   };
   };
   const response = await client.chat.completions.create(syncParams);
   const response = await client.chat.completions.create(syncParams);
 
 

+ 1 - 1
packages/core/src/models/project.ts

@@ -8,7 +8,7 @@ export const LLMConfigSchema = z.object({
   temperature: z.number().min(0).max(2).default(0.7),
   temperature: z.number().min(0).max(2).default(0.7),
   maxTokens: z.number().int().min(1).default(8192),
   maxTokens: z.number().int().min(1).default(8192),
   thinkingBudget: z.number().int().min(0).default(0),
   thinkingBudget: z.number().int().min(0).default(0),
-  reasoningEffort: z.enum(["low", "medium", "high"]).optional(),
+  extra: z.record(z.unknown()).optional(),
   apiFormat: z.enum(["chat", "responses"]).default("chat"),
   apiFormat: z.enum(["chat", "responses"]).default("chat"),
   stream: z.boolean().default(true),
   stream: z.boolean().default(true),
 });
 });

+ 18 - 1
packages/core/src/utils/config-loader.ts

@@ -44,7 +44,24 @@ export async function loadProjectConfig(root: string): Promise<ProjectConfig> {
   if (env.INKOS_LLM_TEMPERATURE) llm.temperature = parseFloat(env.INKOS_LLM_TEMPERATURE);
   if (env.INKOS_LLM_TEMPERATURE) llm.temperature = parseFloat(env.INKOS_LLM_TEMPERATURE);
   if (env.INKOS_LLM_MAX_TOKENS) llm.maxTokens = parseInt(env.INKOS_LLM_MAX_TOKENS, 10);
   if (env.INKOS_LLM_MAX_TOKENS) llm.maxTokens = parseInt(env.INKOS_LLM_MAX_TOKENS, 10);
   if (env.INKOS_LLM_THINKING_BUDGET) llm.thinkingBudget = parseInt(env.INKOS_LLM_THINKING_BUDGET, 10);
   if (env.INKOS_LLM_THINKING_BUDGET) llm.thinkingBudget = parseInt(env.INKOS_LLM_THINKING_BUDGET, 10);
-  if (env.INKOS_LLM_REASONING_EFFORT) llm.reasoningEffort = env.INKOS_LLM_REASONING_EFFORT;
+  // Extra params from env: INKOS_LLM_EXTRA_<key>=<value>
+  const extraFromEnv: Record<string, unknown> = {};
+  for (const [key, value] of Object.entries(env)) {
+    if (key.startsWith("INKOS_LLM_EXTRA_") && value) {
+      const paramName = key.slice("INKOS_LLM_EXTRA_".length);
+      // Auto-coerce: numbers, booleans, JSON objects
+      if (/^\d+(\.\d+)?$/.test(value)) extraFromEnv[paramName] = parseFloat(value);
+      else if (value === "true") extraFromEnv[paramName] = true;
+      else if (value === "false") extraFromEnv[paramName] = false;
+      else if (value.startsWith("{") || value.startsWith("[")) {
+        try { extraFromEnv[paramName] = JSON.parse(value); } catch { extraFromEnv[paramName] = value; }
+      }
+      else extraFromEnv[paramName] = value;
+    }
+  }
+  if (Object.keys(extraFromEnv).length > 0) {
+    llm.extra = { ...(llm.extra as Record<string, unknown> ?? {}), ...extraFromEnv };
+  }
   if (env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT;
   if (env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT;
   config.llm = llm;
   config.llm = llm;