@volter/twin-openai 2.0.6 → 2.0.7

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.
@@ -57,6 +57,8 @@ export const openaiScenarioAdapter = {
57
57
  modelEquals: (req, cond) => nonEmptyString(cond) && req.model === cond,
58
58
  userTextIncludes: (req, cond) => nonEmptyString(cond) && lastUserText(req.messages).toLowerCase().includes(cond.toLowerCase()),
59
59
  anyTextIncludes: (req, cond) => nonEmptyString(cond) && req.messages.map((m) => contentToText(m.content)).join('\n').toLowerCase().includes(cond.toLowerCase()),
60
+ // the latest message alone (a tool's result just returned, not one from earlier in the conversation)
61
+ lastMessageTextIncludes: (req, cond) => nonEmptyString(cond) && contentToText(req.messages[req.messages.length - 1]?.content).toLowerCase().includes(cond.toLowerCase()),
60
62
  lastMessageIsToolResult: (req, cond) => typeof cond === 'boolean' && (req.messages[req.messages.length - 1]?.role === 'tool') === cond,
61
63
  toolResultFor: (req, cond) => nonEmptyString(cond) && lastToolResultNames(req.messages).has(cond),
62
64
  hasTool: (req, cond) => nonEmptyString(cond) && toolNames(req.tools).includes(cond),
@@ -66,7 +68,7 @@ export const openaiScenarioAdapter = {
66
68
  },
67
69
  text: (req) => req.messages.map((m) => contentToText(m.content)).join('\n'),
68
70
  validateOn: (on) => {
69
- for (const k of ['modelEquals', 'userTextIncludes', 'anyTextIncludes', 'toolResultFor', 'hasTool'])
71
+ for (const k of ['modelEquals', 'userTextIncludes', 'anyTextIncludes', 'lastMessageTextIncludes', 'toolResultFor', 'hasTool'])
70
72
  if (on[k] !== undefined && (typeof on[k] !== 'string' || !on[k]))
71
73
  return `on.${k} is a non-empty string`;
72
74
  if (on.lastMessageIsToolResult !== undefined && typeof on.lastMessageIsToolResult !== 'boolean')
@@ -20,8 +20,12 @@ export declare function stubAssistantText(messages: ChatMessageParam[], model: s
20
20
  /**
21
21
  * Build a deterministic stub argument string for a tool. When the tool declares a JSON-schema
22
22
  * `parameters` object (especially with `strict:true`), real models emit arguments that validate
23
- * against the schema; the twin synthesizes a deterministic object containing every declared
24
- * property with a type-appropriate placeholder so `strict` callers parse it cleanly. A required
23
+ * against the schema; the twin synthesizes a deterministic object with a type-appropriate
24
+ * placeholder for each property it sets, so `strict` callers parse it cleanly. A strict tool, or a
25
+ * schema that names no `required` list, gets every declared property (strict makes every property
26
+ * required); otherwise only the required ones: a model leaves an optional parameter it has no reason
27
+ * to set unset, and a placeholder there is a choice nobody made (LibreChat's web_search took an
28
+ * optional `date` enum's first value, "past hour", and its search was refused). A required
25
29
  * string is never left empty: an empty value is schema-valid in type but names nothing, so an
26
30
  * application's tool could not act on it and a story could not follow the call. It takes what the
27
31
  * user's message names (`namedIn`), else a deterministic value of the parameter's own name; an enum
@@ -95,8 +95,12 @@ function namedIn(userText) {
95
95
  /**
96
96
  * Build a deterministic stub argument string for a tool. When the tool declares a JSON-schema
97
97
  * `parameters` object (especially with `strict:true`), real models emit arguments that validate
98
- * against the schema; the twin synthesizes a deterministic object containing every declared
99
- * property with a type-appropriate placeholder so `strict` callers parse it cleanly. A required
98
+ * against the schema; the twin synthesizes a deterministic object with a type-appropriate
99
+ * placeholder for each property it sets, so `strict` callers parse it cleanly. A strict tool, or a
100
+ * schema that names no `required` list, gets every declared property (strict makes every property
101
+ * required); otherwise only the required ones: a model leaves an optional parameter it has no reason
102
+ * to set unset, and a placeholder there is a choice nobody made (LibreChat's web_search took an
103
+ * optional `date` enum's first value, "past hour", and its search was refused). A required
100
104
  * string is never left empty: an empty value is schema-valid in type but names nothing, so an
101
105
  * application's tool could not act on it and a story could not follow the call. It takes what the
102
106
  * user's message names (`namedIn`), else a deterministic value of the parameter's own name; an enum
@@ -109,9 +113,13 @@ export function stubToolArguments(tool, userText = '') {
109
113
  if (!props || typeof props !== 'object')
110
114
  return '{}';
111
115
  const required = new Set(Array.isArray(schema.required) ? schema.required.map(String) : []);
116
+ const strict = tool?.function?.strict === true || tool?.strict === true;
117
+ const onlyRequired = !strict && Array.isArray(schema.required);
112
118
  const words = new Set(userText.toLowerCase().match(/[a-z0-9-]+/g) ?? []);
113
119
  const out = {};
114
120
  for (const [key, def] of Object.entries(props)) {
121
+ if (onlyRequired && !required.has(key))
122
+ continue;
115
123
  const d = def;
116
124
  const named = Array.isArray(d?.enum) ? d.enum.find((e) => typeof e === 'string' && words.has(e.toLowerCase())) : undefined;
117
125
  out[key] = named ?? (required.has(key) && d?.type === 'string' && !Array.isArray(d?.enum) ? namedIn(userText) ?? `twin-${key}` : placeholderForSchema(def));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-openai",
3
- "version": "2.0.6",
3
+ "version": "2.0.7",
4
4
  "description": "Local OpenAI twin — a faithful, stateful local OpenAI API your real `openai` SDK talks to unmodified. The model is stubbed (deterministic) and embeddings are deterministic pseudo-vectors, but the protocol envelope (chat completions/streaming/tool_calls, the Responses API, embeddings, models, files, batches, moderations, fine-tuning, vector stores) is vendor-faithful. Built on @volter/world-core.",
5
5
  "keywords": [
6
6
  "twin",
@@ -54,10 +54,10 @@
54
54
  "postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
55
55
  },
56
56
  "peerDependencies": {
57
- "@volter/world-core": "2.0.6"
57
+ "@volter/world-core": "2.0.7"
58
58
  },
59
59
  "devDependencies": {
60
- "@volter/world-core": "2.0.6",
60
+ "@volter/world-core": "2.0.7",
61
61
  "@volter/world-tooling": "0.1.0",
62
62
  "openai": "^4.104.0",
63
63
  "@types/bun": "^1.2.20",
@@ -68,6 +68,6 @@
68
68
  "node": ">=22.3"
69
69
  },
70
70
  "dependencies": {
71
- "@volter/world-ui": "0.1.6"
71
+ "@volter/world-ui": "0.1.7"
72
72
  }
73
73
  }
@@ -77,6 +77,8 @@ export const openaiScenarioAdapter: PackScenarioAdapter<OpenAIScenarioRequest> =
77
77
  modelEquals: (req, cond) => nonEmptyString(cond) && req.model === cond,
78
78
  userTextIncludes: (req, cond) => nonEmptyString(cond) && lastUserText(req.messages).toLowerCase().includes(cond.toLowerCase()),
79
79
  anyTextIncludes: (req, cond) => nonEmptyString(cond) && req.messages.map((m) => contentToText(m.content)).join('\n').toLowerCase().includes(cond.toLowerCase()),
80
+ // the latest message alone (a tool's result just returned, not one from earlier in the conversation)
81
+ lastMessageTextIncludes: (req, cond) => nonEmptyString(cond) && contentToText((req.messages[req.messages.length - 1] as { content?: unknown } | undefined)?.content as never).toLowerCase().includes(cond.toLowerCase()),
80
82
  lastMessageIsToolResult: (req, cond) => typeof cond === 'boolean' && ((req.messages[req.messages.length - 1] as { role?: string } | undefined)?.role === 'tool') === cond,
81
83
  toolResultFor: (req, cond) => nonEmptyString(cond) && lastToolResultNames(req.messages).has(cond),
82
84
  hasTool: (req, cond) => nonEmptyString(cond) && toolNames(req.tools).includes(cond),
@@ -86,7 +88,7 @@ export const openaiScenarioAdapter: PackScenarioAdapter<OpenAIScenarioRequest> =
86
88
  },
87
89
  text: (req) => req.messages.map((m) => contentToText(m.content)).join('\n'),
88
90
  validateOn: (on) => {
89
- for (const k of ['modelEquals', 'userTextIncludes', 'anyTextIncludes', 'toolResultFor', 'hasTool'] as const) if (on[k] !== undefined && (typeof on[k] !== 'string' || !on[k])) return `on.${k} is a non-empty string`;
91
+ for (const k of ['modelEquals', 'userTextIncludes', 'anyTextIncludes', 'lastMessageTextIncludes', 'toolResultFor', 'hasTool'] as const) if (on[k] !== undefined && (typeof on[k] !== 'string' || !on[k])) return `on.${k} is a non-empty string`;
90
92
  if (on.lastMessageIsToolResult !== undefined && typeof on.lastMessageIsToolResult !== 'boolean') return 'on.lastMessageIsToolResult is a boolean';
91
93
  for (const k of ['maxTokensBelow', 'maxTokensAtLeast'] as const) if (on[k] !== undefined && !positiveNumber(on[k])) return `on.${k} is a positive number`;
92
94
  return null;
@@ -100,8 +100,12 @@ function namedIn(userText: string): string | undefined {
100
100
  /**
101
101
  * Build a deterministic stub argument string for a tool. When the tool declares a JSON-schema
102
102
  * `parameters` object (especially with `strict:true`), real models emit arguments that validate
103
- * against the schema; the twin synthesizes a deterministic object containing every declared
104
- * property with a type-appropriate placeholder so `strict` callers parse it cleanly. A required
103
+ * against the schema; the twin synthesizes a deterministic object with a type-appropriate
104
+ * placeholder for each property it sets, so `strict` callers parse it cleanly. A strict tool, or a
105
+ * schema that names no `required` list, gets every declared property (strict makes every property
106
+ * required); otherwise only the required ones: a model leaves an optional parameter it has no reason
107
+ * to set unset, and a placeholder there is a choice nobody made (LibreChat's web_search took an
108
+ * optional `date` enum's first value, "past hour", and its search was refused). A required
105
109
  * string is never left empty: an empty value is schema-valid in type but names nothing, so an
106
110
  * application's tool could not act on it and a story could not follow the call. It takes what the
107
111
  * user's message names (`namedIn`), else a deterministic value of the parameter's own name; an enum
@@ -113,9 +117,12 @@ export function stubToolArguments(tool: unknown, userText = ''): string {
113
117
  const props = schema && typeof schema === 'object' ? schema.properties : undefined;
114
118
  if (!props || typeof props !== 'object') return '{}';
115
119
  const required = new Set(Array.isArray(schema!.required) ? schema!.required.map(String) : []);
120
+ const strict = (tool as { function?: { strict?: unknown }; strict?: unknown } | undefined)?.function?.strict === true || (tool as { strict?: unknown } | undefined)?.strict === true;
121
+ const onlyRequired = !strict && Array.isArray(schema!.required);
116
122
  const words = new Set(userText.toLowerCase().match(/[a-z0-9-]+/g) ?? []);
117
123
  const out: Record<string, unknown> = {};
118
124
  for (const [key, def] of Object.entries(props)) {
125
+ if (onlyRequired && !required.has(key)) continue;
119
126
  const d = def as { type?: unknown; enum?: unknown[] } | undefined;
120
127
  const named = Array.isArray(d?.enum) ? d!.enum!.find((e) => typeof e === 'string' && words.has(e.toLowerCase())) : undefined;
121
128
  out[key] = named ?? (required.has(key) && d?.type === 'string' && !Array.isArray(d?.enum) ? namedIn(userText) ?? `twin-${key}` : placeholderForSchema(def));