Skip to content

feat(custom): send several values under one ingress header name - #472

Open
Menci wants to merge 2 commits into
mainfrom
feat/custom-ingress-header-multi-value
Open

feat(custom): send several values under one ingress header name#472
Menci wants to merge 2 commits into
mainfrom
feat/custom-ingress-header-multi-value

Conversation

@Menci

@Menci Menci commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Summary

An ingress header rule carried one value per name: persistence and the dashboard both rejected a repeated name, and the resolver wrote each configured value with Headers.set. An operator could not keep the client's value and add one of their own, nor send a name twice.

A rule is now one value rather than one name. A name resolves as a whole — its admitted client values are dropped and its rules rebuild the value list in rule order:

rules for one name client sent nothing client sent a client sent a and b
none
(passthrough) a a, b
one one one one
one, two one, two one, two one, two
(passthrough), one one a, one a, b, one
one, (passthrough) one one, a one, a, b
(empty), one ``, one ``, one ``, one

Each cell lists the values the upstream receives, one field line each. A client that repeats a name contributes one value, because both runtimes merge a repeated name when the inbound request becomes a Headers.

  • A name carries at most one passthrough rule, because the client's values enter the request once. Persistence and the dashboard reject the second one; every other repetition is accepted.
  • A name with no rule reaches no upstream: it is not admitted, and nothing writes it.
  • Admission is unchanged — only passthrough names are admitted, so a configured name never carries the client's copy into the provider.

Field lines end to end

Sending several values means several field lines, which nothing below the resolver could express. HttpRequest.headers was a Record<string, string>, the gateway's dial seam collapsed a Headers into that record — losing repetitions, and silently keeping only the last Set-Cookie — and on the response side every field line was appended into a Headers, which merges a repeated name on undici.

Both directions of @floway-dev/http now carry ordered [name, value] field lines: the serializer writes one line per entry, and the parser records RawHttpResponse.headerLines beside the Headers it still builds for the Web bridge (the decoded Transfer-Encoding leaves both views together). The same shape reaches the transport through UpstreamFetchOptions.extraHeaders and headersForMessagesCall.

The result per egress:

transport repeated name on the wire
direct_connect / proxy (the default) one field line per value
Cloudflare fetch one field line per value — workerd keeps the values as a list
Node direct_fetch combined into one line — undici concatenates inside Headers.append, before any transport sees the request

RFC 9110 §5.3 makes the combined form the same field value for a list-typed name, and direct_fetch is opt-in, so the default egress on both runtimes sends separate lines.

Test Plan

  • The table above, as a case per cell, resolved through the instance's own admission filter and read as field lines rather than through a merging Headers.
  • Every Custom endpoint — Alpha Search, Chat Completions, Completions, Responses generate and compact, Messages, count tokens, Embeddings, Images generations and edits, Audio transcriptions, Rerank — resolves the same rules.
  • Every client protocol through the gateway, including the cross-protocol translations: Messages and Responses and Gemini onto a Chat Completions upstream, Chat Completions onto a Messages upstream, plus count tokens, Embeddings, Completions, Images, Audio, and Rerank.
  • Field lines asserted from a real node:http server's rawHeaders over both Node egress paths: direct_connect sends x-route twice and x-configured twice, direct_fetch sends each combined.
  • @floway-dev/http writes one request field line per entry, and keeps every response field line of a repeated name in wire order and casing, including Set-Cookie.
  • A name repeated with configured values saves from the dashboard; a name passed through twice reports a localized error on the second rule.
  • pnpm run verify — 547 test files and 5,697 tests passed, plus lint, typecheck, installer harness, generated assets, AGENTS validation, verification parity, and web build.

Menci added 2 commits August 16, 2026 15:33
An ingress header rule carried one value per name, so an operator could
not keep the client's value and add one of their own, nor send a name
twice. Both persistence and the dashboard rejected a repeated name
outright.

Model a rule as one value rather than one name. A name resolves as a
whole: its admitted client values are dropped and its rules rebuild the
value list in rule order, so a passthrough rule reinstates what the
client sent and every configured rule contributes its own value beside
it. A name still carries at most one passthrough rule, because the
client's values enter the request once, and a name with no rule reaches
no upstream at all.

Cover the contract as a table over what the operator configured and what
the client sent, run it across every Custom endpoint, and drive each
client protocol — including the cross-protocol translations — through
the gateway. Assert the resulting field lines against a real HTTP server
over both Node egress paths, since undici and our own socket writer
serialize a repeated name independently.
A repeated header name had nowhere to live. `HttpRequest.headers` was a
`Record<string, string>`, so the serializer emitted one line per name and
the gateway's dial seam collapsed a `Headers` into that record — losing
the repetition, and silently keeping only the last Set-Cookie. On the
response side every field line was appended into a `Headers`, which
merges a repeated name on undici, so no caller could read the lines the
upstream actually sent.

Model both directions as ordered field lines. `HttpRequest.headers` and
the new `RawHttpResponse.headerLines` are `[name, value]` in wire order,
the serializer writes one line per entry, and the parser records each
line beside the `Headers` it still builds for the Web bridge. The
decoded Transfer-Encoding leaves both views together.

Carry the same shape through the provider transport boundary:
`UpstreamFetchOptions.extraHeaders` and `headersForMessagesCall` take
field lines, and the Custom provider resolves its ingress rules into
them, so a name configured several times reaches the wire several times.
Node's `direct_fetch` remains the one transport that cannot express it —
undici concatenates inside `Headers.append`, before any transport sees
the request — and its combined form is the same field value per RFC 9110
§5.3.
@Menci

Menci commented Aug 16, 2026

Copy link
Copy Markdown
Owner Author

Live-instance experiment: what a repeated ingress header rule puts on the wire

A script that starts an isolated Floway instance and a capture HTTP server, drives a real client request through the gateway, and records the field lines the upstream actually received. It confirms the wire behaviour this PR describes.

GitHub's attachment endpoint refuses .zip, .tar.gz, .txt, and .md for this repository (only image and video content types are accepted), so the script and its results are inlined below rather than parked in a branch, a release, or an external host.

What it does

  1. Starts a capture HTTP server on an ephemeral port, recording req.rawHeaders — the only lossless view of the field lines a request arrived with.
  2. Boots an isolated Floway Node instance: its own SQLite database and files directory under a fresh mktemp -d, its own port, dev passwordless admin login. Nothing touches an existing instance or checkout state.
  3. Creates one Custom upstream per egress (direct_connect, direct_fetch) pointing at the capture server, both carrying the same ingress header rules.
  4. Sends the client request over a raw TCP socket rather than fetch, so the client's own repeated names reach the gateway as separate field lines.
  5. Writes the results, then tears down the instance, the capture server, and the temporary directory.

Rules under test

name rules what it demonstrates
x-passthrough (passthrough) the client's value survives admission
x-route (passthrough), appended a configured value joins the client's
x-configured first, second two values, no client involvement
x-mixed (empty), after-empty an empty value is one of several
x-dropped none an unruled name never reaches the upstream

The client sent

x-passthrough: kept-a
x-passthrough: kept-b
x-route: client-a
x-route: client-b
x-configured: client-copy
x-dropped: gone

The upstream received

Node v22.23.2, both requests answered HTTP/1.1 200 OK.

direct_connect — the default egress, through our own socket writer:

POST /v1/embeddings HTTP/1.1
x-passthrough: kept-a, kept-b
x-route: client-a, client-b
x-route: appended
x-configured: first
x-configured: second
x-mixed: 
x-mixed: after-empty

direct_fetch — opt-in, through undici:

POST /v1/embeddings HTTP/1.1
x-passthrough: kept-a, kept-b
x-route: client-a, client-b, appended
x-configured: first, second
x-mixed: , after-empty
name direct_connect direct_fetch
x-passthrough kept-a, kept-b (1 line) kept-a, kept-b (1 line)
x-route client-a, client-b, appended (2 lines) client-a, client-b, appended (1 line)
x-configured first, second (2 lines) first, second (1 line)
x-mixed ``, after-empty (2 lines) , after-empty (1 line)
x-dropped absent absent

Two things worth naming:

  • x-passthrough arrives as one line on both paths even though the client sent two. Both runtimes merge a repeated name when the inbound request becomes a Headers, which happens before the gateway sees it — so passthrough contributes one value however many lines the client sent.
  • direct_fetch combines because undici concatenates inside Headers.append, before any transport sees the request. RFC 9110 §5.3 makes that the same field value for a list-typed name, and the default egress on both runtimes sends separate lines.

Download

ingress-header-lines-experiment.zip — the script and its results.

GitHub's attachment endpoint accepts only image and video content types, so the archive is stored under a .png name. The bytes are the zip verbatim, and this one-liner writes them back out under the right name:

curl -sL https://github.com/user-attachments/assets/0f910932-ab23-4037-b253-bd3739abf776 -o ingress-header-lines-experiment.zip && unzip -o ingress-header-lines-experiment.zip

The same content is inlined below, so nothing depends on that link.

experiment.mjs
// Ingress-header field-line experiment.
//
// Boots an isolated Floway Node instance (its own SQLite database, files
// directory, and port) plus a capture HTTP server that stands in for the
// upstream, configures a Custom upstream whose ingress header rules send one
// name several times, then drives real client requests through the gateway and
// records the exact field lines the upstream received.
//
// Node builtins only. Run with: node experiment.mjs [--out <dir>]

import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { connect } from 'node:net';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(HERE, '..', '..');

const outFlag = process.argv.indexOf('--out');
const OUT_DIR = outFlag === -1 ? join(HERE, 'results') : resolve(process.argv[outFlag + 1]);

// One name passed through and extended, one name supplied entirely by the
// operator, one name with an empty value beside a typed one, and one name the
// client sends with no rule at all.
const INGRESS_HEADERS_RULES = [
  { key: 'x-passthrough', value: null },
  { key: 'x-route', value: null },
  { key: 'x-route', value: 'appended' },
  { key: 'x-configured', value: 'first' },
  { key: 'x-configured', value: 'second' },
  { key: 'x-mixed', value: '' },
  { key: 'x-mixed', value: 'after-empty' },
];

// Sent as raw bytes so the client's own repeated names reach the gateway as
// separate field lines — `fetch` would merge them before the request left.
const CLIENT_FIELD_LINES = [
  ['x-passthrough', 'kept-a'],
  ['x-passthrough', 'kept-b'],
  ['x-route', 'client-a'],
  ['x-route', 'client-b'],
  ['x-configured', 'client-copy'],
  ['x-dropped', 'gone'],
];

const EGRESSES = ['direct_connect', 'direct_fetch'];

const sleep = ms => new Promise(done => setTimeout(done, ms));
const step = message => process.stderr.write(`[experiment] ${message}\n`);

const startCaptureUpstream = async () => {
  const captured = [];
  const server = createServer((incoming, response) => {
    const chunks = [];
    incoming.on('data', chunk => chunks.push(chunk));
    incoming.on('end', () => {
      const lines = [];
      for (let index = 0; index < incoming.rawHeaders.length; index += 2) {
        lines.push([incoming.rawHeaders[index], incoming.rawHeaders[index + 1]]);
      }
      captured.push({
        method: incoming.method,
        path: incoming.url,
        httpVersion: incoming.httpVersion,
        fieldLines: lines,
        body: Buffer.concat(chunks).toString('utf8'),
      });
      const body = JSON.stringify({
        object: 'list',
        model: 'embedding-model',
        data: [{ object: 'embedding', index: 0, embedding: [0.1] }],
        usage: { prompt_tokens: 1, total_tokens: 1 },
      });
      response.writeHead(200, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) });
      response.end(body);
    });
  });
  await new Promise(listening => server.listen(0, '127.0.0.1', listening));
  return { server, captured, port: server.address().port };
};

const startFloway = async workDir => {
  const port = 18788 + Math.floor(process.pid % 1000);
  const child = spawn('pnpm', ['--filter', '@floway-dev/platform-node', 'run', 'start'], {
    cwd: REPO,
    env: {
      ...process.env,
      PORT: String(port),
      FLOWAY_DB_PATH: join(workDir, 'floway.db'),
      FLOWAY_FILES_DIR: join(workDir, 'files'),
      // Empty ADMIN_KEY is the dev-instance passwordless admin login.
      ADMIN_KEY: '',
      NODE_ENV: 'development',
    },
    stdio: ['ignore', 'pipe', 'pipe'],
  });
  const log = [];
  child.stdout.on('data', chunk => log.push(chunk.toString()));
  child.stderr.on('data', chunk => log.push(chunk.toString()));

  const base = `http://127.0.0.1:${port}`;
  for (let attempt = 0; attempt < 120; attempt++) {
    if (child.exitCode !== null) throw new Error(`Floway exited early (${child.exitCode}):\n${log.join('')}`);
    try {
      const health = await fetch(`${base}/api/health`);
      if (health.ok) return { child, base, log };
    } catch { /* not listening yet */ }
    await sleep(500);
  }
  throw new Error(`Floway did not become healthy:\n${log.join('')}`);
};

const controlPlane = (base, token) => async (method, path, body) => {
  const response = await fetch(`${base}${path}`, {
    method,
    headers: {
      'content-type': 'application/json',
      ...(token ? { 'x-floway-session': token } : {}),
    },
    ...(body === undefined ? {} : { body: JSON.stringify(body) }),
  });
  const text = await response.text();
  if (!response.ok) throw new Error(`${method} ${path}${response.status}: ${text}`);
  return text ? JSON.parse(text) : null;
};

// Raw HTTP/1.1 so the client's repeated names stay repeated on the wire.
const rawPost = (port, path, fieldLines, body) => new Promise((resolvePromise, reject) => {
  const payload = Buffer.from(body, 'utf8');
  const head = [
    `POST ${path} HTTP/1.1`,
    'Host: 127.0.0.1',
    'Connection: close',
    'Content-Type: application/json',
    `Content-Length: ${payload.byteLength}`,
    ...fieldLines.map(([name, value]) => `${name}: ${value}`),
    '',
    '',
  ].join('\r\n');
  const socket = connect({ host: '127.0.0.1', port }, () => {
    socket.write(head);
    socket.write(payload);
  });
  socket.setTimeout(30_000, () => {
    socket.destroy();
    reject(new Error(`raw POST ${path} timed out after 30s; received so far: ${JSON.stringify(Buffer.concat(chunks).toString('utf8'))}`));
  });
  const chunks = [];
  socket.on('data', chunk => chunks.push(chunk));
  socket.on('error', reject);
  socket.on('end', () => resolvePromise({ request: head, response: Buffer.concat(chunks).toString('utf8') }));
});

const valuesFor = (fieldLines, name) =>
  fieldLines.flatMap(([candidate, value]) => candidate.toLowerCase() === name ? [value] : []);

const main = async () => {
  const workDir = await mkdtemp(join(tmpdir(), 'floway-ingress-header-'));
  step('starting capture upstream');
  const upstream = await startCaptureUpstream();
  let floway;
  try {
    step(`capture upstream on 127.0.0.1:${upstream.port}`);
    step('booting isolated Floway instance');
    floway = await startFloway(workDir);
    step(`Floway healthy at ${floway.base}`);
    const anonymous = controlPlane(floway.base);
    const { token } = await anonymous('POST', '/auth/login', { username: '', password: '' });
    const api = controlPlane(floway.base, token);
    const key = await api('POST', '/api/keys', { name: 'experiment' });
    step('admin session and API key created');

    const observations = [];
    for (const egress of EGRESSES) {
      const created = await api('POST', '/api/upstreams', {
        kind: 'custom',
        name: `capture-${egress}`,
        hue: 210,
        proxy_fallback_list: [{ id: egress }],
        config: {
          baseUrl: `http://127.0.0.1:${upstream.port}`,
          authStyle: 'bearer',
          apiKey: 'sk-experiment',
          endpoints: {},
          ingressHeadersRules: INGRESS_HEADERS_RULES,
          modelsFetch: { enabled: false },
          models: [{ upstreamModelId: `embedding-model-${egress}`, endpoints: { embeddings: {} } }],
        },
      });

      step(`${egress}: upstream created, sending client request`);
      const before = upstream.captured.length;
      const exchange = await rawPost(
        Number(new URL(floway.base).port),
        '/v1/embeddings',
        [['x-api-key', key.key], ...CLIENT_FIELD_LINES],
        JSON.stringify({ model: `embedding-model-${egress}`, input: 'hi' }),
      );
      const received = upstream.captured.slice(before);
      if (received.length !== 1) throw new Error(`${egress}: upstream saw ${received.length} requests`);

      observations.push({
        egress,
        upstreamId: created.id,
        clientRequestHead: exchange.request,
        gatewayResponseStatus: exchange.response.split('\r\n')[0],
        upstreamRequest: received[0],
        values: {
          'x-passthrough': valuesFor(received[0].fieldLines, 'x-passthrough'),
          'x-route': valuesFor(received[0].fieldLines, 'x-route'),
          'x-configured': valuesFor(received[0].fieldLines, 'x-configured'),
          'x-mixed': valuesFor(received[0].fieldLines, 'x-mixed'),
          'x-dropped': valuesFor(received[0].fieldLines, 'x-dropped'),
        },
      });
    }

    await mkdir(OUT_DIR, { recursive: true });
    const result = {
      node: process.version,
      rules: INGRESS_HEADERS_RULES,
      clientFieldLines: CLIENT_FIELD_LINES,
      observations,
    };
    await writeFile(join(OUT_DIR, 'results.json'), `${JSON.stringify(result, null, 2)}\n`);
    await writeFile(join(OUT_DIR, 'floway.log'), floway.log.join(''));
    await writeFile(join(OUT_DIR, 'report.md'), report(result));
    process.stdout.write(report(result));
  } finally {
    if (floway) {
      floway.child.kill('SIGTERM');
      await sleep(500);
      floway.child.kill('SIGKILL');
    }
    await new Promise(closed => upstream.server.close(() => closed()));
    await rm(workDir, { recursive: true, force: true });
  }
};

const report = result => {
  const rows = result.observations.map(observation => {
    const lines = observation.upstreamRequest.fieldLines
      .filter(([name]) => name.toLowerCase().startsWith('x-'))
      .map(([name, value]) => `    ${name}: ${value}`)
      .join('\n');
    return [
      `### egress \`${observation.egress}\``,
      '',
      `Gateway answered \`${observation.gatewayResponseStatus}\`. The upstream received:`,
      '',
      '```http',
      `    ${observation.upstreamRequest.method} ${observation.upstreamRequest.path} HTTP/${observation.upstreamRequest.httpVersion}`,
      lines,
      '```',
      '',
      Object.entries(observation.values)
        .map(([name, values]) => `- \`${name}\` → ${values.length === 0 ? '_(absent)_' : values.map(value => `\`${value}\``).join(', ')} (${values.length} field line${values.length === 1 ? '' : 's'})`)
        .join('\n'),
      '',
    ].join('\n');
  });

  return [
    '# Ingress header field lines — live instance',
    '',
    `Node ${result.node}. An isolated Floway instance and a capture HTTP server, both started by the script.`,
    '',
    '## Configured rules',
    '',
    '```json',
    JSON.stringify(result.rules, null, 2),
    '```',
    '',
    '## Client field lines',
    '',
    '```http',
    result.clientFieldLines.map(([name, value]) => `    ${name}: ${value}`).join('\n'),
    '```',
    '',
    ...rows,
  ].join('\n');
};

await main();
results.json
{
  "node": "v22.23.2",
  "rules": [
    {
      "key": "x-passthrough",
      "value": null
    },
    {
      "key": "x-route",
      "value": null
    },
    {
      "key": "x-route",
      "value": "appended"
    },
    {
      "key": "x-configured",
      "value": "first"
    },
    {
      "key": "x-configured",
      "value": "second"
    },
    {
      "key": "x-mixed",
      "value": ""
    },
    {
      "key": "x-mixed",
      "value": "after-empty"
    }
  ],
  "clientFieldLines": [
    [
      "x-passthrough",
      "kept-a"
    ],
    [
      "x-passthrough",
      "kept-b"
    ],
    [
      "x-route",
      "client-a"
    ],
    [
      "x-route",
      "client-b"
    ],
    [
      "x-configured",
      "client-copy"
    ],
    [
      "x-dropped",
      "gone"
    ]
  ],
  "observations": [
    {
      "egress": "direct_connect",
      "upstreamId": "up_ef679f304ac84104bc4969ed",
      "clientRequestHead": "POST /v1/embeddings HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: 55\r\nx-api-key: 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\r\nx-passthrough: kept-a\r\nx-passthrough: kept-b\r\nx-route: client-a\r\nx-route: client-b\r\nx-configured: client-copy\r\nx-dropped: gone\r\n\r\n",
      "gatewayResponseStatus": "HTTP/1.1 200 OK",
      "upstreamRequest": {
        "method": "POST",
        "path": "/v1/embeddings",
        "httpVersion": "1.1",
        "fieldLines": [
          [
            "Host",
            "127.0.0.1:64712"
          ],
          [
            "authorization",
            "Bearer sk-experiment"
          ],
          [
            "content-type",
            "application/json"
          ],
          [
            "x-passthrough",
            "kept-a, kept-b"
          ],
          [
            "x-route",
            "client-a, client-b"
          ],
          [
            "x-route",
            "appended"
          ],
          [
            "x-configured",
            "first"
          ],
          [
            "x-configured",
            "second"
          ],
          [
            "x-mixed",
            ""
          ],
          [
            "x-mixed",
            "after-empty"
          ],
          [
            "Connection",
            "close"
          ],
          [
            "Content-Length",
            "55"
          ]
        ],
        "body": "{\"input\":\"hi\",\"model\":\"embedding-model-direct_connect\"}"
      },
      "values": {
        "x-passthrough": [
          "kept-a, kept-b"
        ],
        "x-route": [
          "client-a, client-b",
          "appended"
        ],
        "x-configured": [
          "first",
          "second"
        ],
        "x-mixed": [
          "",
          "after-empty"
        ],
        "x-dropped": []
      }
    },
    {
      "egress": "direct_fetch",
      "upstreamId": "up_66e0d3c6dd4147169f61fdef",
      "clientRequestHead": "POST /v1/embeddings HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: 53\r\nx-api-key: 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\r\nx-passthrough: kept-a\r\nx-passthrough: kept-b\r\nx-route: client-a\r\nx-route: client-b\r\nx-configured: client-copy\r\nx-dropped: gone\r\n\r\n",
      "gatewayResponseStatus": "HTTP/1.1 200 OK",
      "upstreamRequest": {
        "method": "POST",
        "path": "/v1/embeddings",
        "httpVersion": "1.1",
        "fieldLines": [
          [
            "host",
            "127.0.0.1:64712"
          ],
          [
            "connection",
            "keep-alive"
          ],
          [
            "Authorization",
            "Bearer sk-experiment"
          ],
          [
            "Content-Type",
            "application/json"
          ],
          [
            "x-passthrough",
            "kept-a, kept-b"
          ],
          [
            "x-route",
            "client-a, client-b, appended"
          ],
          [
            "x-configured",
            "first, second"
          ],
          [
            "x-mixed",
            ", after-empty"
          ],
          [
            "accept",
            "*/*"
          ],
          [
            "accept-language",
            "*"
          ],
          [
            "sec-fetch-mode",
            "cors"
          ],
          [
            "user-agent",
            "node"
          ],
          [
            "accept-encoding",
            "gzip, deflate"
          ],
          [
            "content-length",
            "53"
          ]
        ],
        "body": "{\"input\":\"hi\",\"model\":\"embedding-model-direct_fetch\"}"
      },
      "values": {
        "x-passthrough": [
          "kept-a, kept-b"
        ],
        "x-route": [
          "client-a, client-b, appended"
        ],
        "x-configured": [
          "first, second"
        ],
        "x-mixed": [
          ", after-empty"
        ],
        "x-dropped": []
      }
    }
  ]
}
instance log

> @floway-dev/platform-node@0.0.0 start <repo>/apps/platform-node
> tsx entry.ts

(node:91536) ExperimentalWarning: SQLite is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
Floway listening on http://localhost:19075
<-- GET /api/health
--> GET /api/health 200 2ms
<-- POST /auth/login
--> POST /auth/login 200 2ms
<-- POST /api/keys
--> POST /api/keys 201 2ms
<-- POST /api/upstreams
--> POST /api/upstreams 201 7ms
<-- POST /v1/embeddings
--> POST /v1/embeddings 200 16ms
<-- POST /api/upstreams
--> POST /api/upstreams 201 3ms
<-- POST /v1/embeddings
--> POST /v1/embeddings 200 14ms

Save the script anywhere under the repository root and run it with node <path>/experiment.mjs — it resolves the repository from its own location, two levels up. It needs no network access and leaves nothing behind.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant