@@ -8,27 +8,51 @@ import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
88
99export interface OptionsItemData {
1010 title : string
11- description ? : string
11+ description : string
1212}
1313
14- export type OptionsTagData = Record < string , OptionsItemData | string >
14+ export type OptionsTagData = Record < string , OptionsItemData >
1515
16+ export const USAGE_UPGRADE_ACTIONS = [ 'upgrade_plan' , 'increase_limit' ] as const
17+
18+ export type UsageUpgradeAction = ( typeof USAGE_UPGRADE_ACTIONS ) [ number ]
19+
20+ /**
21+ * Synthetic inline tag payload derived from request-layer HTTP upgrade/quota
22+ * failures and rendered through the same special-tag abstraction as streamed tags.
23+ */
1624export interface UsageUpgradeTagData {
1725 reason : string
18- action : 'upgrade_plan' | 'increase_limit'
26+ action : UsageUpgradeAction
1927 message : string
2028}
2129
30+ export const CREDENTIAL_TAG_TYPES = [
31+ 'env_key' ,
32+ 'oauth_key' ,
33+ 'sim_key' ,
34+ 'credential_id' ,
35+ 'link' ,
36+ ] as const
37+
38+ export type CredentialTagType = ( typeof CREDENTIAL_TAG_TYPES ) [ number ]
39+
2240export interface CredentialTagData {
2341 value : string
24- type : 'env_key' | 'oauth_key' | 'sim_key' | 'credential_id' | 'link'
42+ type : CredentialTagType
2543 provider ?: string
2644}
2745
2846export interface MothershipErrorTagData {
2947 message : string
30- code : string
31- provider : string
48+ code ?: string
49+ provider ?: string
50+ }
51+
52+ export interface FileTagData {
53+ name : string
54+ type : string
55+ content : string
3256}
3357
3458export type ContentSegment =
@@ -39,11 +63,26 @@ export type ContentSegment =
3963 | { type : 'credential' ; data : CredentialTagData }
4064 | { type : 'mothership-error' ; data : MothershipErrorTagData }
4165
66+ export type RuntimeSpecialTagName =
67+ | 'thinking'
68+ | 'options'
69+ | 'credential'
70+ | 'mothership-error'
71+ | 'file'
72+
4273export interface ParsedSpecialContent {
4374 segments : ContentSegment [ ]
4475 hasPendingTag : boolean
4576}
4677
78+ const RUNTIME_SPECIAL_TAG_NAMES = [
79+ 'thinking' ,
80+ 'options' ,
81+ 'credential' ,
82+ 'mothership-error' ,
83+ 'file' ,
84+ ] as const
85+
4786const SPECIAL_TAG_NAMES = [
4887 'thinking' ,
4988 'options' ,
@@ -52,6 +91,125 @@ const SPECIAL_TAG_NAMES = [
5291 'mothership-error' ,
5392] as const
5493
94+ function isRecord ( value : unknown ) : value is Record < string , unknown > {
95+ return typeof value === 'object' && value !== null
96+ }
97+
98+ function isOptionsItemData ( value : unknown ) : value is OptionsItemData {
99+ if ( ! isRecord ( value ) ) return false
100+ return typeof value . title === 'string' && typeof value . description === 'string'
101+ }
102+
103+ function isOptionsTagData ( value : unknown ) : value is OptionsTagData {
104+ if ( ! isRecord ( value ) ) return false
105+ return Object . values ( value ) . every ( isOptionsItemData )
106+ }
107+
108+ function isUsageUpgradeTagData ( value : unknown ) : value is UsageUpgradeTagData {
109+ if ( ! isRecord ( value ) ) return false
110+ return (
111+ typeof value . reason === 'string' &&
112+ typeof value . message === 'string' &&
113+ typeof value . action === 'string' &&
114+ ( USAGE_UPGRADE_ACTIONS as readonly string [ ] ) . includes ( value . action )
115+ )
116+ }
117+
118+ function isCredentialTagData ( value : unknown ) : value is CredentialTagData {
119+ if ( ! isRecord ( value ) ) return false
120+ return (
121+ typeof value . value === 'string' &&
122+ typeof value . type === 'string' &&
123+ ( CREDENTIAL_TAG_TYPES as readonly string [ ] ) . includes ( value . type ) &&
124+ ( value . provider === undefined || typeof value . provider === 'string' )
125+ )
126+ }
127+
128+ function isMothershipErrorTagData ( value : unknown ) : value is MothershipErrorTagData {
129+ if ( ! isRecord ( value ) ) return false
130+ return (
131+ typeof value . message === 'string' &&
132+ ( value . code === undefined || typeof value . code === 'string' ) &&
133+ ( value . provider === undefined || typeof value . provider === 'string' )
134+ )
135+ }
136+
137+ export function parseJsonTagBody < T > (
138+ body : string ,
139+ isExpectedShape : ( value : unknown ) => value is T
140+ ) : T | null {
141+ try {
142+ const parsed = JSON . parse ( body ) as unknown
143+ return isExpectedShape ( parsed ) ? parsed : null
144+ } catch {
145+ return null
146+ }
147+ }
148+
149+ export function parseTextTagBody ( body : string ) : string | null {
150+ return body . trim ( ) ? body : null
151+ }
152+
153+ export function parseTagAttributes ( openTag : string ) : Record < string , string > {
154+ const attributes : Record < string , string > = { }
155+ const attributePattern = / ( [ A - Z a - z _ : ] [ A - Z a - z 0 - 9 _ : - ] * ) = " ( [ ^ " ] * ) " / g
156+
157+ let match : RegExpExecArray | null = null
158+ while ( ( match = attributePattern . exec ( openTag ) ) !== null ) {
159+ attributes [ match [ 1 ] ] = match [ 2 ]
160+ }
161+
162+ return attributes
163+ }
164+
165+ export function parseFileTag ( openTag : string , body : string ) : FileTagData | null {
166+ const attributes = parseTagAttributes ( openTag )
167+ if ( ! attributes . name || ! attributes . type ) return null
168+ return {
169+ name : attributes . name ,
170+ type : attributes . type ,
171+ content : body ,
172+ }
173+ }
174+
175+ function parseSpecialTagData (
176+ tagName : ( typeof SPECIAL_TAG_NAMES ) [ number ] ,
177+ body : string
178+ ) :
179+ | { type : 'thinking' ; content : string }
180+ | { type : 'options' ; data : OptionsTagData }
181+ | { type : 'usage_upgrade' ; data : UsageUpgradeTagData }
182+ | { type : 'credential' ; data : CredentialTagData }
183+ | { type : 'mothership-error' ; data : MothershipErrorTagData }
184+ | null {
185+ if ( tagName === 'thinking' ) {
186+ const content = parseTextTagBody ( body )
187+ return content ? { type : 'thinking' , content } : null
188+ }
189+
190+ if ( tagName === 'options' ) {
191+ const data = parseJsonTagBody ( body , isOptionsTagData )
192+ return data ? { type : 'options' , data } : null
193+ }
194+
195+ if ( tagName === 'usage_upgrade' ) {
196+ const data = parseJsonTagBody ( body , isUsageUpgradeTagData )
197+ return data ? { type : 'usage_upgrade' , data } : null
198+ }
199+
200+ if ( tagName === 'credential' ) {
201+ const data = parseJsonTagBody ( body , isCredentialTagData )
202+ return data ? { type : 'credential' , data } : null
203+ }
204+
205+ if ( tagName === 'mothership-error' ) {
206+ const data = parseJsonTagBody ( body , isMothershipErrorTagData )
207+ return data ? { type : 'mothership-error' , data } : null
208+ }
209+
210+ return null
211+ }
212+
55213/**
56214 * Parses inline special tags (`<options>`, `<usage_upgrade>`) from streamed
57215 * text content. Complete tags are extracted into typed segments; incomplete
@@ -68,7 +226,7 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS
68226
69227 while ( cursor < content . length ) {
70228 let nearestStart = - 1
71- let nearestTagName = ''
229+ let nearestTagName : ( typeof SPECIAL_TAG_NAMES ) [ number ] | '' = ''
72230
73231 for ( const name of SPECIAL_TAG_NAMES ) {
74232 const idx = content . indexOf ( `<${ name } >` , cursor )
@@ -85,7 +243,10 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS
85243 const partial = remaining . match ( / < [ a - z _ - ] * $ / i)
86244 if ( partial ) {
87245 const fragment = partial [ 0 ] . slice ( 1 )
88- if ( fragment . length > 0 && SPECIAL_TAG_NAMES . some ( ( t ) => t . startsWith ( fragment ) ) ) {
246+ if (
247+ fragment . length > 0 &&
248+ [ ...SPECIAL_TAG_NAMES , ...RUNTIME_SPECIAL_TAG_NAMES ] . some ( ( t ) => t . startsWith ( fragment ) )
249+ ) {
89250 remaining = remaining . slice ( 0 , - partial [ 0 ] . length )
90251 hasPendingTag = true
91252 }
@@ -117,20 +278,13 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS
117278 }
118279
119280 const body = content . slice ( bodyStart , closeIdx )
120- if ( nearestTagName === 'thinking' ) {
121- if ( body . trim ( ) ) {
122- segments . push ( { type : 'thinking' , content : body } )
123- }
124- } else {
125- try {
126- const data = JSON . parse ( body )
127- segments . push ( {
128- type : nearestTagName as 'options' | 'usage_upgrade' | 'credential' | 'mothership-error' ,
129- data,
130- } )
131- } catch {
132- /* malformed JSON — drop the tag silently */
133- }
281+ if ( ! nearestTagName ) {
282+ cursor = closeIdx + closeTag . length
283+ continue
284+ }
285+ const parsedTag = parseSpecialTagData ( nearestTagName , body )
286+ if ( parsedTag ) {
287+ segments . push ( parsedTag )
134288 }
135289
136290 cursor = closeIdx + closeTag . length
@@ -211,7 +365,7 @@ function OptionsDisplay({ data, onSelect }: OptionsDisplayProps) {
211365 < span className = 'font-base text-[14px] text-[var(--text-body)]' > Suggested follow-ups</ span >
212366 < div className = 'mt-1.5 flex flex-col' >
213367 { entries . map ( ( [ key , value ] , i ) => {
214- const title = typeof value === 'string' ? value : value . title
368+ const title = value . title
215369
216370 return (
217371 < button
0 commit comments