Skip to content
skyl

Calling the gateway from another language

Complete Python and TypeScript clients — auth, completion, streaming, and the tool loop.

The gateway exists so that services which are not written in Go can reach the same models through the same interface. This page is one complete client in each language: authentication, a completion, streaming, the tool-calling loop, and error handling.

What you need#

The gateway speaks ordinary JSON over HTTP. There is no SDK to install in any language, and there never will be — the wire format is the interface.

export SKYL_URL=http://localhost:8080
export SKYL_TOKEN=your-gateway-token
export SKYL_URL=http://localhost:8080
export SKYL_TOKEN=your-gateway-token

A completion#

curl -sS "$SKYL_URL/v1/chat" \
  -H "Authorization: Bearer $SKYL_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "provider": "anthropic",
    "model": "claude-opus-5",
    "max_tokens": 512,
    "messages": [{"role": "user", "text": "Explain Go channels in two sentences."}]
  }'
curl -sS "$SKYL_URL/v1/chat" \
  -H "Authorization: Bearer $SKYL_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "provider": "anthropic",
    "model": "claude-opus-5",
    "max_tokens": 512,
    "messages": [{"role": "user", "text": "Explain Go channels in two sentences."}]
  }'

text is every text part concatenated — the convenience field for the common case. message carries the assistant's full turn, which you need for tools.

Streaming#

POST /v1/chat/stream returns text/event-stream. Each frame is one JSON object on a data: line, and the connection carries periodic keep-alive comments that you must skip.

curl -N -sS "$SKYL_URL/v1/chat/stream" \
  -H "Authorization: Bearer $SKYL_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"model":"gpt-5.6","max_tokens":256,
       "messages":[{"role":"user","text":"Count to five."}]}'
curl -N -sS "$SKYL_URL/v1/chat/stream" \
  -H "Authorization: Bearer $SKYL_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"model":"gpt-5.6","max_tokens":256,
       "messages":[{"role":"user","text":"Count to five."}]}'

The tool loop#

This is the part worth reading closely. The gateway returns the assistant's turn in message, in the same shape a request takes, so you send it straight back followed by your results.

textCompiles
def run_tool(name, arguments):
    if name == "get_weather":
        return f"22C and sunny in {arguments['city']}"
    return f"ERROR: no such tool {name}"


messages = [{"role": "user", "text": "What's the weather in Kampala?"}]
tools = [{
    "name": "get_weather",
    "description": "Get the current weather for a city. Call this whenever the "
                   "user asks about weather in a named place.",
    "parameters": {
        "type": "object",
        "properties": {"city": {"type": "string"}},
        "required": ["city"],
    },
}]

for _ in range(5):                       # bounded: a model can loop forever
    r = httpx.post(f"{BASE}/v1/chat", headers=HEADERS, timeout=120.0, json={
        "model": "claude-opus-5",
        "max_tokens": 512,
        "messages": messages,
        "tools": tools,
    })
    r.raise_for_status()
    body = r.json()

    calls = body.get("tool_calls") or []
    if not calls:
        print(body["text"])
        break

    # The assistant's turn must be replayed BEFORE any result: every provider
    # rejects a tool result that does not follow the call it answers.
    messages.append(body["message"])

    # And every call must be answered, or the whole turn is invalid.
    for call in calls:
        messages.append({
            "role": "tool",
            "content": [{
                "type": "tool_result",
                "tool_call_id": call["id"],
                "content": run_tool(call["name"], call["arguments"]),
            }],
        })
def run_tool(name, arguments):
    if name == "get_weather":
        return f"22C and sunny in {arguments['city']}"
    return f"ERROR: no such tool {name}"


messages = [{"role": "user", "text": "What's the weather in Kampala?"}]
tools = [{
    "name": "get_weather",
    "description": "Get the current weather for a city. Call this whenever the "
                   "user asks about weather in a named place.",
    "parameters": {
        "type": "object",
        "properties": {"city": {"type": "string"}},
        "required": ["city"],
    },
}]

for _ in range(5):                       # bounded: a model can loop forever
    r = httpx.post(f"{BASE}/v1/chat", headers=HEADERS, timeout=120.0, json={
        "model": "claude-opus-5",
        "max_tokens": 512,
        "messages": messages,
        "tools": tools,
    })
    r.raise_for_status()
    body = r.json()

    calls = body.get("tool_calls") or []
    if not calls:
        print(body["text"])
        break

    # The assistant's turn must be replayed BEFORE any result: every provider
    # rejects a tool result that does not follow the call it answers.
    messages.append(body["message"])

    # And every call must be answered, or the whole turn is invalid.
    for call in calls:
        messages.append({
            "role": "tool",
            "content": [{
                "type": "tool_result",
                "tool_call_id": call["id"],
                "content": run_tool(call["name"], call["arguments"]),
            }],
        })

Errors#

Every failure returns JSON with a stable, machine-readable kind, so you branch on that rather than parsing prose.

skyl errorHTTPkindNote
ErrAuth502authThe gateway's own provider credential was rejected. Not 401 — the caller did nothing wrong.
ErrRateLimit429rate_limitReturned only after skyl exhausted its retries upstream.
ErrNotFound404not_foundNo such model for this account — usually a typo, since model IDs are not validated.
ErrBadRequest400bad_requestMalformed request, caught by local validation or by the provider.
ErrUnsupported400unsupportedThe provider cannot express part of the request — an image URL on Gemini, say.
ErrRefusal422refusalThe model or its safety classifiers declined. Do not retry the same request.
ErrServer502serverThe provider failed on its side.
(anything else)502unknownThe default. 502 is the fallback for an unclassified upstream failure.
textCompiles
r = httpx.post(f"{BASE}/v1/chat", headers=HEADERS, timeout=120.0, json=payload)

if r.status_code >= 400:
    err = r.json()
    kind = err.get("kind", "unknown")
    if kind == "rate_limit":
        pass      # the gateway already retried upstream; shed load here
    elif kind == "refusal":
        pass      # never retry: the same prompt gets the same answer
    elif kind == "auth":
        pass      # a 502 means the GATEWAY's provider key was rejected, not yours
    raise RuntimeError(f"{kind}: {err.get('error')}")
r = httpx.post(f"{BASE}/v1/chat", headers=HEADERS, timeout=120.0, json=payload)

if r.status_code >= 400:
    err = r.json()
    kind = err.get("kind", "unknown")
    if kind == "rate_limit":
        pass      # the gateway already retried upstream; shed load here
    elif kind == "refusal":
        pass      # never retry: the same prompt gets the same answer
    elif kind == "auth":
        pass      # a 502 means the GATEWAY's provider key was rejected, not yours
    raise RuntimeError(f"{kind}: {err.get('error')}")

What you give up#

The gateway exposes the wire format, not the Go library, so a few things do not cross the boundary:

  • Response.Raw is omitted unless the operator sets SKYL_INCLUDE_RAW, because a provider's untouched body can echo request content back to a caller.
  • Hooks are a library concept. The gateway's own /metrics covers observability instead.
  • Client tuning — retries, timeouts — is the operator's, set through environment variables, not per request.

Everything else is the same interface described throughout this documentation.

Edit this page on GitHub