@@ -857,12 +857,15 @@ function openAiCompatibleDefaultEndpoint(
857857}
858858
859859function nativeApiBase ( endpoint : string | undefined , fallback : string ) : string {
860- const trimmed = ( endpoint || fallback ) . trim ( ) . replace ( / \/ + $ / g , "" ) ;
860+ const trimmed = stripTrailingSlashes ( ( endpoint || fallback ) . trim ( ) ) ;
861861 if ( ! trimmed ) throw new Error ( "Provider endpoint is required." ) ;
862- return trimmed
863- . replace ( / \/ v 1 (?: \/ .* ) ? $ / g, "" )
864- . replace ( / \/ a p i \/ v 1 (?: \/ .* ) ? $ / g, "" )
865- . replace ( / \/ a p i (?: \/ .* ) ? $ / g, "" ) ;
862+ return stripPathFromSegment (
863+ stripPathFromSegment (
864+ stripPathFromSegment ( trimmed , "/v1" ) ,
865+ "/api/v1"
866+ ) ,
867+ "/api"
868+ ) ;
866869}
867870
868871function ollamaApiUrl ( endpoint : string | undefined , route : "chat" | "tags" ) : string {
@@ -880,28 +883,31 @@ function requireAnthropicApiKey(apiKey: string | undefined): string {
880883}
881884
882885export function openAiChatCompletionsUrl ( endpoint : string ) : string {
883- const trimmed = endpoint . trim ( ) . replace ( / \/ + $ / g , "" ) ;
886+ const trimmed = stripTrailingSlashes ( endpoint . trim ( ) ) ;
884887 if ( ! trimmed ) throw new Error ( "OpenAI-compatible endpoint is required." ) ;
885888 if ( trimmed . endsWith ( "/chat/completions" ) ) return trimmed ;
886889 if ( trimmed . endsWith ( "/v1" ) ) return `${ trimmed } /chat/completions` ;
887890 return `${ trimmed } /v1/chat/completions` ;
888891}
889892
890893export function openAiModelsUrl ( endpoint : string ) : string {
891- const trimmed = endpoint . trim ( ) . replace ( / \/ + $ / g , "" ) ;
894+ const trimmed = stripTrailingSlashes ( endpoint . trim ( ) ) ;
892895 if ( ! trimmed ) throw new Error ( "OpenAI-compatible endpoint is required." ) ;
893896 if ( trimmed . endsWith ( "/models" ) ) return trimmed ;
894- if ( trimmed . endsWith ( "/chat/completions" ) ) return `${ trimmed . replace ( / \/ c h a t \/ c o m p l e t i o n s $ / g, "" ) } /models` ;
897+ if ( trimmed . endsWith ( "/chat/completions" ) ) {
898+ return `${ trimmed . slice ( 0 , - "/chat/completions" . length ) } /models` ;
899+ }
895900 if ( trimmed . endsWith ( "/v1" ) ) return `${ trimmed } /models` ;
896901 return `${ trimmed } /v1/models` ;
897902}
898903
899904function providerModelsUrls ( endpoint : string ) : string [ ] {
900- const trimmed = endpoint . trim ( ) . replace ( / \/ + $ / g , "" ) ;
905+ const trimmed = stripTrailingSlashes ( endpoint . trim ( ) ) ;
901906 const urls = [ openAiModelsUrl ( trimmed ) ] ;
902- const withoutVersion = trimmed
903- . replace ( / \/ v 1 (?: \/ .* ) ? $ / g, "" )
904- . replace ( / \/ a p i \/ v 1 (?: \/ .* ) ? $ / g, "" ) ;
907+ const withoutVersion = stripPathFromSegment (
908+ stripPathFromSegment ( trimmed , "/v1" ) ,
909+ "/api/v1"
910+ ) ;
905911 if ( withoutVersion ) {
906912 urls . push ( `${ withoutVersion } /api/v1/models` ) ;
907913 urls . push ( `${ withoutVersion } /v1/models` ) ;
@@ -934,10 +940,10 @@ export function parseJsonObjectFromText(input: string): { ok: true; value: unkno
934940 const trimmed = input . trim ( ) ;
935941 if ( ! trimmed ) return { ok : false , error : "empty response" } ;
936942
937- const fenced = / ` ` ` (?: j s o n ) ? \s * ( [ \s \S ] * ? ) ` ` ` / i . exec ( trimmed ) ;
943+ const fenced = extractMarkdownFence ( trimmed ) ;
938944 const candidates = [
939945 trimmed ,
940- fenced ?. [ 1 ] ,
946+ fenced ,
941947 extractBalancedJson ( trimmed )
942948 ] . filter ( ( candidate ) : candidate is string => Boolean ( candidate ?. trim ( ) ) ) ;
943949
@@ -956,6 +962,51 @@ export function parseJsonObjectFromText(input: string): { ok: true; value: unkno
956962 return { ok : false , error : "no parseable JSON object found" } ;
957963}
958964
965+ /** Removes only terminal slash characters with one bounded reverse scan. */
966+ function stripTrailingSlashes ( value : string ) : string {
967+ let end = value . length ;
968+ while ( end > 0 && value . charCodeAt ( end - 1 ) === 47 ) end -= 1 ;
969+ return end === value . length ? value : value . slice ( 0 , end ) ;
970+ }
971+
972+ /**
973+ * Mirrors the former endpoint suffix normalization without an unanchored
974+ * wildcard expression. A segment qualifies only at the end or before another
975+ * slash, and the first qualifying segment wins just as left-to-right matching
976+ * did. Provider egress still crosses authorizedProviderFetch afterwards.
977+ */
978+ function stripPathFromSegment ( value : string , segment : string ) : string {
979+ let offset = 0 ;
980+ while ( offset < value . length ) {
981+ const index = value . indexOf ( segment , offset ) ;
982+ if ( index < 0 ) return value ;
983+ const boundary = index + segment . length ;
984+ if ( boundary === value . length || value . charCodeAt ( boundary ) === 47 ) {
985+ return value . slice ( 0 , index ) ;
986+ }
987+ offset = index + 1 ;
988+ }
989+ return value ;
990+ }
991+
992+ /** Extracts the first Markdown fence in linear time without regex backtracking. */
993+ function extractMarkdownFence ( value : string ) : string | undefined {
994+ const opening = value . indexOf ( "```" ) ;
995+ if ( opening < 0 ) return undefined ;
996+ let contentStart = opening + 3 ;
997+ if ( value . slice ( contentStart , contentStart + 4 ) . toLowerCase ( ) === "json" ) {
998+ contentStart += 4 ;
999+ }
1000+ while (
1001+ contentStart < value . length &&
1002+ value [ contentStart ] ! . trim ( ) . length === 0
1003+ ) {
1004+ contentStart += 1 ;
1005+ }
1006+ const closing = value . indexOf ( "```" , contentStart ) ;
1007+ return closing < 0 ? undefined : value . slice ( contentStart , closing ) ;
1008+ }
1009+
9591010async function callOllamaText (
9601011 config : AiProviderConfig ,
9611012 messages : AiChatMessage [ ] ,
0 commit comments