src/stop-sequences.js
Rama master · réplica de lectura del repositorio original.
/**
* OpenAI `stop` sequence enforcement (proto-openai-03).
*
* The OpenAI /v1/chat/completions path accepted `stop` (string | string[]) and
* folded it into the cache key, but NEVER enforced it: the upstream Devin wire
* has no calibrated stop field, so the model ran to its own natural end and the
* caller's stop sequence was silently ignored. Clients that rely on `stop` to
* fence output (e.g. "stop at \n\n" or a custom sentinel) got over-long
* completions.
*
* Fix = local enforcement, mirroring what the Anthropic path already does for
* stop_sequences (handlers/messages.js): truncate the assistant text at the
* FIRST occurrence of any stop sequence and report finish_reason:'stop'. The
* matched sequence itself is removed from the output (OpenAI semantics — the
* stop text is not included in the returned content).
*/
// OpenAI documents only the count limit (4 sequences); no per-sequence length
// is published. Locally a sequence's length IS the enforcement cost: the
// streaming gate holds back (len - 1) chars per delta and indexOf-scans that
// window for every upstream chunk, so an unbounded sequence is an unbounded
// per-delta scan AND an unbounded holdback buffer. Cap each entry at 1024
// chars — far past any real-world sentinel. An over-limit sequence is DROPPED,
// not truncated: a truncated sequence could match where the caller's full
// sequence never would, cutting output upstream would have returned (wrong
// truncation). Dropping is fail-open — the request behaves as if that sequence
// were absent, exactly like the non-string / empty entries already discarded
// here — and can never invent a stop the caller did not write.
export const MAX_STOP_SEQUENCE_CHARS = 1024;
// OpenAI allows a bare string or an array of up to 4 strings. Normalize to a
// clean string[] (drop non-strings / empties / over-long entries); return []
// when nothing usable.
export function normalizeStop(stop) {
const usable = (s) => typeof s === 'string' && s.length > 0 && s.length <= MAX_STOP_SEQUENCE_CHARS;
if (typeof stop === 'string') return usable(stop) ? [stop] : [];
if (Array.isArray(stop)) {
return stop.filter(usable).slice(0, 4);
}
return [];
}
// Non-streaming: truncate `text` at the earliest stop-sequence hit. Returns
// { text, hit, stop } — hit is true when any sequence matched (→ finish_reason:'stop'),
// and `stop` is the EXACT sequence that won (null when nothing matched). Callers
// that must report the cause — the Anthropic Messages route echoes it as
// `stop_sequence` — need the cause at THIS layer: the matched bytes are removed
// from the returned text, so nothing downstream can recover which sequence fired.
// When multiple sequences appear, the one with the EARLIEST index wins so the
// output is the shortest correct prefix; on an exact tie the earlier entry in the
// caller's list wins (unchanged).
export function applyStop(text, stopSequences) {
if (typeof text !== 'string' || !text) return { text: text || '', hit: false, stop: null };
const seqs = normalizeStop(stopSequences);
if (!seqs.length) return { text, hit: false, stop: null };
let cut = -1;
let matched = null;
for (const seq of seqs) {
const idx = text.indexOf(seq);
if (idx !== -1 && (cut === -1 || idx < cut)) { cut = idx; matched = seq; }
}
if (cut === -1) return { text, hit: false, stop: null };
return { text: text.slice(0, cut), hit: true, stop: matched };
}
/**
* Streaming enforcement. A stop sequence can straddle two content chunks, so we
* hold back a tail of up to (maxSeqLen - 1) chars until we know it is NOT the
* start of a stop sequence. Usage per stream:
*
* const gate = new StopSequenceGate(stop);
* for each content chunk:
* const { emit, hit } = gate.push(chunk);
* if (emit) send(emit);
* if (hit) { finish_reason = 'stop'; break; } // suppress the rest
* // if the stream ends naturally:
* const tail = gate.flush(); if (tail) send(tail);
*/
export class StopSequenceGate {
constructor(stop) {
this.seqs = normalizeStop(stop);
this.maxLen = this.seqs.reduce((m, s) => Math.max(m, s.length), 0);
this.buf = '';
this.done = false;
// The sequence that ended the stream (null until a hit). Same reason as
// applyStop's `stop`: the matched bytes never reach the caller's text, so the
// cause has to be captured where the match happened.
this.matched = null;
}
get active() { return this.seqs.length > 0; }
// Feed one content delta. Returns { emit, hit }:
// emit — the safe-to-send prefix (may be '')
// hit — true once a stop sequence completed; caller should stop the stream.
// After a hit, further push() calls return nothing.
push(chunk) {
if (!this.active || this.done) {
return { emit: this.done ? '' : (chunk || ''), hit: false };
}
this.buf += (chunk || '');
// Earliest stop match in the running buffer (ties keep the earlier list entry).
let cut = -1;
let matched = null;
for (const seq of this.seqs) {
const idx = this.buf.indexOf(seq);
if (idx !== -1 && (cut === -1 || idx < cut)) { cut = idx; matched = seq; }
}
if (cut !== -1) {
const emit = this.buf.slice(0, cut);
this.buf = '';
this.done = true;
this.matched = matched;
return { emit, hit: true };
}
// No full match yet. Emit everything except a trailing (maxLen-1) window that
// could still become the head of a stop sequence in the next chunk.
const hold = this.maxLen - 1;
if (this.buf.length <= hold) return { emit: '', hit: false };
const emit = this.buf.slice(0, this.buf.length - hold);
this.buf = this.buf.slice(this.buf.length - hold);
return { emit, hit: false };
}
// Stream ended with no stop hit — release whatever is still held.
flush() {
if (this.done) return '';
const out = this.buf;
this.buf = '';
return out;
}
}