Example: calling an AI model

Call OpenAI or Anthropic's Claude from a SupaHost Edge Function, keep the API key server-side, require a signed-in user and stream the answer to the browser.

AI model APIs are a natural fit for Edge Functions: the API key stays on the server, you decide who may call the model, and you can stream the answer back so users see it as it's written. This page builds ask-chef, a function for a recipe app that answers cooking questions. It shows OpenAI first, then the same function with Anthropic's Claude.

Before you start

  • Add your provider's key in Dashboard → your project → Functions tab → Secrets: OPENAI_API_KEY or ANTHROPIC_API_KEY.
  • For OpenAI, also add OPENAI_MODEL with the model you want to use, picked from OpenAI's current model list. Keeping it in a secret lets you switch models without a redeploy.
  • Keep Require a JWT on. The function also checks that a real user is signed in, because the public anon key passes the JWT check by itself and anyone could otherwise spend your API credits.

OpenAI version

This calls the Chat Completions API with stream: true and turns OpenAI's server-sent events into a plain text stream, so the browser code stays simple.

import { createClient } from "jsr:@supabase/supabase-js@2";

const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY")!;
const OPENAI_MODEL = Deno.env.get("OPENAI_MODEL");

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};

const SYSTEM_PROMPT =
  "You are a friendly chef. Answer cooking questions in under 200 words. " +
  "If a question isn't about food or cooking, say you can only help with cooking.";

/** Turn OpenAI's SSE stream into a stream of plain text chunks. */
function textFromOpenAiStream(body: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
  const encoder = new TextEncoder();
  let buffer = "";
  return body
    .pipeThrough(new TextDecoderStream())
    .pipeThrough(
      new TransformStream<string, Uint8Array>({
        transform(chunk, controller) {
          buffer += chunk;
          const lines = buffer.split("\n");
          buffer = lines.pop() ?? ""; // keep a partial line for the next chunk
          for (const line of lines) {
            if (!line.startsWith("data:")) continue;
            const data = line.slice(5).trim();
            if (data === "[DONE]") continue;
            const delta = JSON.parse(data).choices?.[0]?.delta?.content;
            if (delta) controller.enqueue(encoder.encode(delta));
          }
        },
      }),
    );
}

Deno.serve(async (req) => {
  if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });

  if (!OPENAI_MODEL) {
    return Response.json({ error: "Set the OPENAI_MODEL secret" }, { status: 500, headers: corsHeaders });
  }

  // Only signed-in users may call the model.
  const authHeader = req.headers.get("Authorization") ?? "";
  const supabase = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!);
  const { data: { user } } = await supabase.auth.getUser(authHeader.replace(/^Bearer\s+/i, ""));
  if (!user) return Response.json({ error: "Sign in first" }, { status: 401, headers: corsHeaders });

  const { question } = await req.json().catch(() => ({ question: "" }));
  if (typeof question !== "string" || question.trim().length === 0 || question.length > 2000) {
    return Response.json({ error: "Ask a question of up to 2000 characters" }, { status: 400, headers: corsHeaders });
  }

  const upstream = await fetch("https://api.openai.com/v1/chat/completions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${OPENAI_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: OPENAI_MODEL,
      stream: true,
      messages: [
        { role: "system", content: SYSTEM_PROMPT },
        { role: "user", content: question },
      ],
    }),
  });

  if (!upstream.ok || !upstream.body) {
    console.error("OpenAI error", upstream.status, await upstream.text());
    return Response.json({ error: "The model is unavailable, try again" }, { status: 502, headers: corsHeaders });
  }

  console.log(`ask-chef: user ${user.id} asked ${question.length} chars`);
  return new Response(textFromOpenAiStream(upstream.body), {
    headers: { ...corsHeaders, "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-cache" },
  });
});

Claude version

With Anthropic, use the official TypeScript SDK through an npm: import. Pin the version you test with (for example npm:@anthropic-ai/sdk@<version>). The sign-in and input checks are the same as in the OpenAI version; the model call and the stream are different:

import Anthropic from "npm:@anthropic-ai/sdk";
import { createClient } from "jsr:@supabase/supabase-js@2";

const anthropic = new Anthropic({ apiKey: Deno.env.get("ANTHROPIC_API_KEY") });

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};

const SYSTEM_PROMPT =
  "You are a friendly chef. Answer cooking questions in under 200 words. " +
  "If a question isn't about food or cooking, say you can only help with cooking.";

Deno.serve(async (req) => {
  if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });

  const authHeader = req.headers.get("Authorization") ?? "";
  const supabase = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!);
  const { data: { user } } = await supabase.auth.getUser(authHeader.replace(/^Bearer\s+/i, ""));
  if (!user) return Response.json({ error: "Sign in first" }, { status: 401, headers: corsHeaders });

  const { question } = await req.json().catch(() => ({ question: "" }));
  if (typeof question !== "string" || question.trim().length === 0 || question.length > 2000) {
    return Response.json({ error: "Ask a question of up to 2000 characters" }, { status: 400, headers: corsHeaders });
  }

  const encoder = new TextEncoder();
  const body = new ReadableStream<Uint8Array>({
    start(controller) {
      const stream = anthropic.messages.stream({
        model: "claude-opus-5-5",
        max_tokens: 16000,
        system: SYSTEM_PROMPT,
        messages: [{ role: "user", content: question }],
      });
      // Forward each piece of text as soon as it arrives.
      stream.on("text", (text) => controller.enqueue(encoder.encode(text)));
      stream.finalMessage()
        .then((message) => {
          if (message.stop_reason === "refusal") {
            controller.enqueue(encoder.encode("\n\nSorry, I can't help with that one."));
          }
          controller.close();
        })
        .catch((err) => {
          console.error("Anthropic error", err);
          controller.error(err);
        });
    },
  });

  return new Response(body, {
    headers: { ...corsHeaders, "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-cache" },
  });
});

Reading the stream in the browser

Both versions return plain text chunks. Use fetch (not functions.invoke) so you can read the body as it arrives:

const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error("Sign in first");

const res = await fetch(`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/ask-chef`, {
  method: "POST",
  headers: { Authorization: `Bearer ${session.access_token}`, "Content-Type": "application/json" },
  body: JSON.stringify({ question: "How do I keep risotto creamy without cream?" }),
});
if (!res.ok || !res.body) throw new Error(`ask-chef failed: ${res.status}`);

const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let answer = "";
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  answer += value;
  renderAnswer(answer); // update your UI as text arrives
}

Limits to keep in mind

  • 150 seconds to the first byte. Streaming starts sending almost immediately, so long answers are fine. A non-streaming call that takes longer than 150 seconds fails with 504 IDLE_TIMEOUT.
  • 150 MB of memory per worker. Streaming passes text through without holding the whole answer.
  • Cost control. Cap input length (as above), and consider a per-user daily quota: insert a row per call into a usage table and count today's rows before calling the model.

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us