@agjs/tsforge 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,148 @@
1
+ import type {
2
+ IChatMessage,
3
+ ICompleteOptions,
4
+ IOpenAICompatibleConfig,
5
+ ReasoningStyle,
6
+ } from "./inference.types";
7
+ import { PROVIDER_LIMITS } from "./inference.constants";
8
+ import { toWire } from "./wire";
9
+
10
+ /** Interpolate `${VAR}` references from `env` into a string (missing → ""). */
11
+ function interpolateEnv(
12
+ value: string,
13
+ env: Readonly<Record<string, string | undefined>>
14
+ ): string {
15
+ return value.replace(
16
+ /\$\{([A-Za-z0-9_]+)\}/g,
17
+ (_m: string, name: string) => env[name] ?? ""
18
+ );
19
+ }
20
+
21
+ function style(cfg: IOpenAICompatibleConfig): ReasoningStyle {
22
+ return cfg.reasoning ?? "qwen";
23
+ }
24
+
25
+ /** Provider-specific reasoning/thinking fields for the request body. */
26
+ function reasoningFields(
27
+ cfg: IOpenAICompatibleConfig,
28
+ opts: ICompleteOptions
29
+ ): Record<string, unknown> {
30
+ switch (style(cfg)) {
31
+ case "qwen":
32
+ return {
33
+ ...(opts.enableThinking === undefined
34
+ ? {}
35
+ : { chat_template_kwargs: { enable_thinking: opts.enableThinking } }),
36
+ ...(opts.thinkingTokenBudget === undefined
37
+ ? {}
38
+ : { thinking_token_budget: opts.thinkingTokenBudget }),
39
+ };
40
+ case "deepseek":
41
+ return {
42
+ ...(opts.enableThinking === undefined
43
+ ? {}
44
+ : {
45
+ thinking: {
46
+ type: opts.enableThinking ? "enabled" : "disabled",
47
+ },
48
+ }),
49
+ ...(cfg.reasoningEffort === undefined
50
+ ? {}
51
+ : { reasoning_effort: cfg.reasoningEffort }),
52
+ };
53
+ case "openai":
54
+ return cfg.reasoningEffort === undefined
55
+ ? {}
56
+ : { reasoning_effort: cfg.reasoningEffort };
57
+ case "none":
58
+ return {};
59
+ }
60
+ }
61
+
62
+ /** The output-token cap field — o-series renamed `max_tokens` → `max_completion_tokens`. */
63
+ function tokenCapField(cfg: IOpenAICompatibleConfig): Record<string, number> {
64
+ const max = cfg.maxTokens ?? PROVIDER_LIMITS.maxTokens;
65
+
66
+ return style(cfg) === "openai"
67
+ ? { max_completion_tokens: max }
68
+ : { max_tokens: max };
69
+ }
70
+
71
+ /** Tool-choice clamped for provider constraints: DeepSeek's thinking mode rejects
72
+ * `tool_choice: "required"`, so downgrade it to `"auto"` there. */
73
+ function toolChoiceFor(
74
+ cfg: IOpenAICompatibleConfig,
75
+ requested: "auto" | "required" | "none"
76
+ ): "auto" | "required" | "none" {
77
+ if (style(cfg) === "deepseek" && requested === "required") {
78
+ return "auto";
79
+ }
80
+
81
+ return requested;
82
+ }
83
+
84
+ /** Build the request body object (pure). Field order keeps the qwen default
85
+ * byte-for-byte identical; `extraBody` is merged last so it can override
86
+ * anything for a fully custom provider. */
87
+ export function buildRequestBody(
88
+ cfg: IOpenAICompatibleConfig,
89
+ messages: IChatMessage[],
90
+ opts: ICompleteOptions,
91
+ streaming: boolean
92
+ ): Record<string, unknown> {
93
+ // o-series rejects `temperature` entirely; everywhere else send it only when set.
94
+ const omitTemperature =
95
+ style(cfg) === "openai" || opts.temperature === undefined;
96
+
97
+ return {
98
+ model: cfg.model,
99
+ messages: messages.map(toWire),
100
+ ...tokenCapField(cfg),
101
+ ...(omitTemperature ? {} : { temperature: opts.temperature }),
102
+ ...(cfg.repetitionPenalty === undefined
103
+ ? {}
104
+ : { repetition_penalty: cfg.repetitionPenalty }),
105
+ ...(opts.tools === undefined
106
+ ? {}
107
+ : {
108
+ tools: opts.tools,
109
+ tool_choice: toolChoiceFor(cfg, opts.toolChoice ?? "auto"),
110
+ }),
111
+ ...reasoningFields(cfg, opts),
112
+ ...(streaming
113
+ ? { stream: true, stream_options: { include_usage: true } }
114
+ : {}),
115
+ ...(cfg.extraBody ?? {}),
116
+ };
117
+ }
118
+
119
+ /** Build request headers: JSON + Bearer auth (when a key is set) + any
120
+ * `extraHeaders` (with `${VAR}` interpolation), which can override the defaults. */
121
+ export function buildRequestHeaders(
122
+ cfg: IOpenAICompatibleConfig,
123
+ env: Readonly<Record<string, string | undefined>> = process.env
124
+ ): Record<string, string> {
125
+ const headers: Record<string, string> = {
126
+ "content-type": "application/json",
127
+ };
128
+
129
+ if (cfg.apiKey !== undefined) {
130
+ headers.authorization = `Bearer ${cfg.apiKey}`;
131
+ }
132
+
133
+ for (const [key, value] of Object.entries(cfg.extraHeaders ?? {})) {
134
+ headers[key] = interpolateEnv(value, env);
135
+ }
136
+
137
+ return headers;
138
+ }
139
+
140
+ /** Normalize the chat-completions URL: trim trailing slashes and don't
141
+ * double-append when the baseUrl already ends with the path. */
142
+ export function chatCompletionsUrl(baseUrl: string): string {
143
+ const trimmed = baseUrl.replace(/\/+$/, "");
144
+
145
+ return trimmed.endsWith("/chat/completions")
146
+ ? trimmed
147
+ : `${trimmed}/chat/completions`;
148
+ }
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { mkdir, readFile, writeFile, chmod } from "node:fs/promises";
4
4
  import { isRecord } from "./lib/guards";
5
5
  import { PROVIDER_DEFAULTS } from "./inference/inference.constants";
6
+ import type { ReasoningStyle } from "./inference/inference.types";
6
7
 
7
8
  /**
8
9
  * The model registry — `~/.tsforge/models.json`, the central place a user
@@ -28,6 +29,18 @@ export interface IModelEntry {
28
29
  thinking?: boolean;
29
30
  /** Per-response token cap override. */
30
31
  maxTokens?: number;
32
+ /** Provider reasoning dialect: how thinking/reasoning is expressed on the wire.
33
+ * `qwen` (default) | `deepseek` | `openai` | `none`. Set `deepseek` for the
34
+ * DeepSeek API, `openai` for OpenAI o-series. */
35
+ reasoning?: ReasoningStyle;
36
+ /** Reasoning effort for `deepseek`/`openai` styles. */
37
+ reasoningEffort?: "low" | "medium" | "high";
38
+ /** Arbitrary fields merged into the request body (override built-ins) — the
39
+ * escape hatch for any provider-specific param. */
40
+ extraBody?: Record<string, unknown>;
41
+ /** Arbitrary request headers (e.g. a non-Bearer auth scheme); `${VAR}` values
42
+ * are interpolated from the environment. */
43
+ extraHeaders?: Record<string, string>;
31
44
  }
32
45
 
33
46
  export interface IModelsConfig {