Integrations OpenAI Responses API
openai responses api

Wire deny.sh into the OpenAI Responses API function-calling loop.

The Responses API is OpenAI's current function-calling surface and the migration target for the Assistants API (which is deprecated and shuts down on 26 August 2026). The shape is a turn loop: send input + tools to client.responses.create, inspect response.output for function_call items, resolve them server-side, then send a follow-up call with function_call_output items and previous_response_id. The deniable pattern resolves the credential between the tool call and the tool result, so the real key never enters the conversation state.

Note: Assistants API migration

If you have an existing Assistants integration: OpenAI's deprecations page confirms a 26 August 2026 shutdown. The deniable pattern is the same; the API surface is different. Migrate to Responses now; the snippet below is drop-in equivalent.

Jump to code ↓

Prerequisites

TypeScript: Responses API turn loop

Show code — ~58 lines, typescript
// npm install openai deny-sh@^2.0.4
import OpenAI from 'openai';
import { vaultGet } from 'deny-sh/client';

const client = new OpenAI();
const tools = [{
  type: 'function' as const,
  name: 'get_invoice',
  description: 'Look up a Stripe invoice by id',
  strict: true,
  parameters: {
    type: 'object',
    properties: { id: { type: 'string' } },
    required: ['id'],
    additionalProperties: false,
  },
}];

let resp = await client.responses.create({
  model: 'gpt-5.5',
  input: 'Look up invoice in_1Abc123XYZ',
  tools,
});

// Walk function_call items, resolve server-side, send results back.
while (true) {
  const calls = resp.output.filter(o => o.type === 'function_call');
  if (calls.length === 0) break; // model is done; resp.output_text is the answer

  const outputs = [];
  for (const call of calls) {
    if (call.name === 'get_invoice') {
      const { id } = JSON.parse(call.arguments);
      // resolve the credential INSIDE the tool boundary
      const stripeKey = await vaultGet('stripe-prod', process.env.VAULT_PW!);
      const r = await fetch(`https://api.stripe.com/v1/invoices/${id}`, {
        headers: { Authorization: `Bearer ${stripeKey}` },
      });
      // narrowed DTO: pick only the fields the model needs. Never return
      // raw upstream JSON, error bodies, or response headers to the model.
      const body = r.ok ? await r.json() : null;
      const dto = body
        ? { id: body.id, amount_due: body.amount_due, status: body.status }
        : { error: 'invoice_lookup_failed', status: r.status };
      outputs.push({
        type: 'function_call_output' as const,
        call_id: call.call_id,
        output: JSON.stringify(dto),
      });
    }
  }
  resp = await client.responses.create({
    model: 'gpt-5.5',
    previous_response_id: resp.id, // chain server-side state
    input: outputs,
    tools,
  });
}

Why this is the deniable shape

OpenAI's infrastructure sees only: the tool schema (no key), the model's tool-call arguments (no key), and the tool output (the invoice JSON, no key). The Stripe key never appears in the input, the tool definition, the system prompt, or any function_call_output. A prompt-injection that tries to print STRIPE_KEY returns the model's apology because the key was never in its context window.

Python variant

Show code — ~53 lines, python
# pip install openai deny-sh requests
import os, json, requests
from openai import OpenAI
from deny_sh import vault_get

client = OpenAI()
tools = [{
    "type": "function",
    "name": "get_invoice",
    "description": "Look up a Stripe invoice by id",
    "parameters": {
        "type": "object",
        "properties": {"id": {"type": "string"}},
        "required": ["id"],
        "additionalProperties": False,
    },
}]

resp = client.responses.create(
    model="gpt-5.5",
    input="Look up invoice in_1Abc123XYZ",
    tools=tools,
)

while True:
    calls = [o for o in resp.output if o.type == "function_call"]
    if not calls:
        break  # model is done; resp.output_text is the answer
    outputs = []
    for call in calls:
        if call.name == "get_invoice":
            args = json.loads(call.arguments)
            # resolve the credential INSIDE the tool boundary
            stripe_key = vault_get("stripe-prod", os.environ["VAULT_PW"])
            r = requests.get(f"https://api.stripe.com/v1/invoices/{args['id']}",
                headers={"Authorization": f"Bearer {stripe_key}"})
            # narrowed DTO: never feed raw upstream JSON back to the model
            if r.ok:
                b = r.json()
                dto = {"id": b.get("id"), "amount_due": b.get("amount_due"), "status": b.get("status")}
            else:
                dto = {"error": "invoice_lookup_failed", "status": r.status_code}
            outputs.append({
                "type": "function_call_output",
                "call_id": call.call_id,
                "output": json.dumps(dto),
            })
    resp = client.responses.create(
        model="gpt-5.5",
        previous_response_id=resp.id,
        input=outputs,
        tools=tools,
    )

Multi-tenant Responses

The Responses API chains state through previous_response_id rather than a long-lived Assistant or Thread. Per-tenant deniability is therefore per-conversation: each end-user has their own deny.sh tenant API key and vault entries, and the credential resolver only runs against THAT tenant's vault inside the tool boundary.

Cross-tenant deniability is enforced at the deny.sh layer: tenant A's vault_get cannot return tenant B's plaintext even if the labels collide. If you store previous_response_id on your end to resume sessions, scope that storage per tenant too; OpenAI does not enforce tenancy across response chains.

Troubleshooting

The first five errors a launch-day developer hits when copy-pasting the snippets above. Click to expand the fix.

BadRequestError: Assistants API has been deprecated

OpenAI deprecated the Assistants API on 2025-08-20; shutdown is 2026-08-26. Old tutorials use client.beta.assistants.create(...) + Threads + Runs. The current path is the Responses API (client.responses.create(...)) + Conversations API for state.

Fix: replace client.beta.assistants + client.beta.threads with client.responses.create(model, input, tools, previous_response_id). The function-calling shape is the same; only the wrapper changed.

response has no .tool_calls on first call

The Responses API emits a list of output items, not the old tool_calls array. Each item has a type field; you iterate and dispatch on type == 'function_call'.

Fix: for item in response.output: if item.type == 'function_call': dispatch(item.name, item.arguments). The call_id on the item is what you echo back on the function_call_output input on the next turn.

OPENAI_API_KEY or DENY_API_KEY not loading in serverless

Vercel/Netlify/Lambda functions get env vars at cold-start only. If you've added a new secret since the last deployment, redeploy or trigger a fresh invocation. process.env.OPENAI_API_KEY being undefined in production usually means the secret was added but the function wasn't redeployed.

Fix: confirm via a one-shot diagnostic route that returns typeof process.env.OPENAI_API_KEY. Remove it after confirming. Never log the value itself.

previous_response_id conversations leak across tenants

OpenAI's Responses API stores chain state server-side keyed by your API key, not by tenant. If you reuse one OpenAI key across tenants and pass previous_response_id from a different tenant's session, the model sees the wrong tenant's history.

Fix: scope previous_response_id storage in your database by your tenant id. Optionally use one OpenAI key per high-stakes tenant. The deny.sh layer keeps credentials isolated; OpenAI's conversation state is a separate boundary you have to enforce.

vault_get returned the wrong key after rotation

If you ran vault_store('stripe-prod', new_key) with a different tenant password than the original, the old ciphertext is still under the original password. vault_get returns whichever ciphertext decrypts under the supplied password.

Fix: always rotate vault entries with the same tenant password you originally registered, OR roll the password globally and re-store every label under it in one sweep. There is no "merge passwords" path; that would defeat deniability.

Production checklist