[object Object]

← back to AbramsOS

chat: add funded OpenAI GPT-5.2 external lane (Responses API) through the redact+NER boundary (TK-11084)

fe14679e26fa89b697a2f37022eb5ffd6789a89f · 2026-09-01 17:43:15 -0700 · Steve

Files touched

Diff

commit fe14679e26fa89b697a2f37022eb5ffd6789a89f
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Sep 1 17:43:15 2026 -0700

    chat: add funded OpenAI GPT-5.2 external lane (Responses API) through the redact+NER boundary (TK-11084)
---
 lib/chat-providers/index.js  |  3 +++
 lib/chat-providers/openai.js | 62 ++++++++++++++++++++++++++++++++++++++++++++
 lib/chat-registry.js         | 11 ++++++++
 tests/chat.test.js           | 28 +++++++++++++++++++-
 4 files changed, 103 insertions(+), 1 deletion(-)

diff --git a/lib/chat-providers/index.js b/lib/chat-providers/index.js
index 3921b09..f603e8f 100644
--- a/lib/chat-providers/index.js
+++ b/lib/chat-providers/index.js
@@ -3,6 +3,7 @@
 const gemini = require('./gemini');
 const ollama = require('./ollama');
 const claudeCli = require('./claude-cli');
+const openai = require('./openai');
 
 async function dispatch(model, { system, messages }) {
   const args = { system, messages, providerModel: model.providerModel };
@@ -13,6 +14,8 @@ async function dispatch(model, { system, messages }) {
       return ollama.chat(args);
     case 'claude-cli':
       return claudeCli.chat(args);
+    case 'openai':
+      return openai.chat(args);
     default:
       throw new Error(`unknown provider: ${model.provider}`);
   }
diff --git a/lib/chat-providers/openai.js b/lib/chat-providers/openai.js
new file mode 100644
index 0000000..850b897
--- /dev/null
+++ b/lib/chat-providers/openai.js
@@ -0,0 +1,62 @@
+// OpenAI adapter — EXTERNAL lane. Mirrors the proven `ask-openai` call path
+// (Responses API, gpt-5.2). Prompt is already redacted + NER-scrubbed by the
+// route before it reaches here (AGENTS.md: no PII to a model provider without
+// redaction). AGENTS.md bans only the ANTHROPIC key — OpenAI is permitted.
+
+const BASE = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1';
+const CHAT_TIMEOUT_MS = 60_000;
+
+function key() {
+  const k = process.env.OPENAI_API_KEY;
+  if (!k) throw new Error('OPENAI_API_KEY not set');
+  return k;
+}
+
+// Pull the text out of a Responses-API payload (output_text convenience field,
+// else the output[].content[] blocks of type output_text).
+function extractText(j) {
+  if (typeof j.output_text === 'string' && j.output_text.trim()) return j.output_text.trim();
+  const parts = [];
+  for (const item of j.output || []) {
+    for (const c of item.content || []) {
+      if (c.type === 'output_text' && typeof c.text === 'string') parts.push(c.text);
+    }
+  }
+  return parts.join('').trim();
+}
+
+async function chat({ system, messages, providerModel }) {
+  const model = providerModel || 'gpt-5.2';
+  // Responses API accepts an array of {role, content} as `input`; `instructions`
+  // carries the system prompt.
+  const input = (messages || []).map((m) => ({
+    role: m.role === 'assistant' ? 'assistant' : 'user',
+    content: String(m.content ?? ''),
+  }));
+
+  const ctrl = new AbortController();
+  const t = setTimeout(() => ctrl.abort(), CHAT_TIMEOUT_MS);
+  try {
+    const r = await fetch(`${BASE}/responses`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Authorization: `Bearer ${key()}`,
+      },
+      body: JSON.stringify({ model, instructions: system || undefined, input }),
+      signal: ctrl.signal,
+    });
+    if (!r.ok) {
+      const body = (await r.text().catch(() => '')).slice(0, 300);
+      throw new Error(`openai ${r.status}: ${body}`);
+    }
+    const j = await r.json();
+    const text = extractText(j);
+    if (!text) throw new Error('openai returned empty response');
+    return { text };
+  } finally {
+    clearTimeout(t);
+  }
+}
+
+module.exports = { chat, extractText, BASE };
diff --git a/lib/chat-registry.js b/lib/chat-registry.js
index fd3d204..6ee6a52 100644
--- a/lib/chat-registry.js
+++ b/lib/chat-registry.js
@@ -62,6 +62,17 @@ const MODELS = [
     cost: 'Max-plan (no metered API)',
     dataNote: 'External — Anthropic via local CLI. PII is redacted before send.',
   },
+  {
+    id: 'openai-gpt-5.2',
+    label: 'GPT-5.2 (OpenAI)',
+    provider: 'openai',
+    providerModel: 'gpt-5.2',
+    visibility: 'external',
+    contextWindow: 'large',
+    speed: 'medium',
+    cost: 'metered · OpenAI account',
+    dataNote: 'External — OpenAI Responses API. PII is redacted + NER-scrubbed before send.',
+  },
 ];
 
 // Each agent = a system prompt + a data-scope. `scope` names the grounding
diff --git a/tests/chat.test.js b/tests/chat.test.js
index ac116fb..42e0e1b 100644
--- a/tests/chat.test.js
+++ b/tests/chat.test.js
@@ -78,7 +78,7 @@ test('registry defaults resolve and every model has a known visibility', () => {
   assert.ok(registry.AGENT_BY_ID[registry.DEFAULT_AGENT_ID], 'default agent missing');
   for (const m of registry.MODELS) {
     assert.ok(['external', 'local'].includes(m.visibility), `bad visibility on ${m.id}`);
-    assert.ok(['gemini', 'ollama', 'claude-cli'].includes(m.provider), `bad provider on ${m.id}`);
+    assert.ok(['gemini', 'ollama', 'claude-cli', 'openai'].includes(m.provider), `bad provider on ${m.id}`);
   }
   // Ollama lanes must be local (raw PII allowed); gemini/claude must be external.
   for (const m of registry.MODELS) {
@@ -289,3 +289,29 @@ test('ner.detectNames: FAIL-CLOSED — throws when local Ollama is unavailable',
     );
   });
 });
+
+// ── OpenAI (GPT) lane: Responses-API parse + registry membership ─────────────
+const openaiAdapter = require('../lib/chat-providers/openai');
+
+test('openai.extractText: reads output_text convenience field', () => {
+  assert.strictEqual(openaiAdapter.extractText({ output_text: 'hello' }), 'hello');
+});
+
+test('openai.extractText: reads output[].content[] blocks', () => {
+  const j = { output: [{ content: [{ type: 'output_text', text: 'grounded ' }, { type: 'output_text', text: 'answer' }] }] };
+  assert.strictEqual(openaiAdapter.extractText(j), 'grounded answer');
+});
+
+test('openai.chat: parses a stubbed Responses payload', async () => {
+  await withFetch(async () => ({ ok: true, json: async () => ({ output_text: 'pong' }), text: async () => '' }), async () => {
+    const { text } = await openaiAdapter.chat({ system: 'sys', messages: [{ role: 'user', content: 'ping' }], providerModel: 'gpt-5.2' });
+    assert.strictEqual(text, 'pong');
+  });
+});
+
+test('registry: OpenAI GPT lane is registered as an EXTERNAL (redacted) model', () => {
+  const m = registry.MODEL_BY_ID['openai-gpt-5.2'];
+  assert.ok(m, 'openai-gpt-5.2 missing from registry');
+  assert.strictEqual(m.provider, 'openai');
+  assert.strictEqual(m.visibility, 'external', 'OpenAI lane must be external so PII is redacted before send');
+});

← 9098e9c chat: guard nav-bar chat behind authed userId (hide on unaut  ·  back to AbramsOS  ·  chat: default lane = GPT-5.2 (funded external) instead of ol a0e1b82 →